求职之路-学习


求职之路 — 学习总结

操作系统

1. 在进程切换的时候会发生什么?

在操作系统中,进程切换(也称为上下文切换)是指将 CPU 从一个进程切换到另一个进程的过程。这个过程由操作系统内核负责管理,并确保系统资源能够在多个进程之间公平和有效地分配。以下是进程切换过程中发生的一些关键步骤:

进程切换的主要步骤

  1. 保存当前进程的状态:
  • CPU寄存器:当前进程的所有CPU寄存器(如程序计数器、堆栈指针、通用寄存器等)被保存到进程的控制块(Process Control Block,PCB)中。
  • 内存管理信息:当前进程的内存管理信息(如页表或段表)也被保存。
  1. 更新进程状态:
  • 当前进程的状态在其PCB中被标记为“等待”或“就绪”状态,以反映它不再占用CPU。
  1. 选择下一个进程:
  • 操作系统的调度器选择下一个要运行的进程。选择算法可能是基于优先级、时间片轮转、最短剩余时间优先等调度策略。
  1. 恢复下一个进程的状态:
  • CPU寄存器:从下一个进程的PCB中恢复所有的CPU寄存器。
  • 内存管理信息:加载下一个进程的内存管理信息,以确保CPU能够正确访问进程的内存。
  1. 更新内存映射:
  • 如果使用虚拟内存,操作系统可能需要更新内存映射(如页表)以确保CPU访问的是当前进程的内存。
  1. 更新进程状态:
  • 被选中的进程状态在其PCB中被标记为“运行”状态,表示它正在占用CPU。
  1. 切换上下文:
    最后一步,CPU的上下文被切换到新的进程,这包括跳转到新的程序计数器地址,并开始执行该进程的代码。

示例:进程切换的汇编代码
以下是一个简化的进程切换示例,以x86-64汇编代码展示了保存和恢复寄存器的基本步骤:

复制代码
; 保存当前进程的上下文
save_context:
    ; 保存通用寄存器
    push rax
    push rbx
    push rcx
    push rdx
    push rsi
    push rdi
    push rbp
    push r8
    push r9
    push r10
    push r11
    push r12
    push r13
    push r14
    push r15

    ; 保存指令指针和标志寄存器
    pushfq
    mov [current_process_rsp], rsp  ; 保存当前进程的堆栈指针

    ; 切换到下一个进程
    mov rsp, [next_process_rsp]     ; 恢复下一个进程的堆栈指针

; 恢复下一个进程的上下文
restore_context:
    popfq
    pop r15
    pop r14
    pop r13
    pop r12
    pop r11
    pop r10
    pop r9
    pop r8
    pop rbp
    pop rdi
    pop rsi
    pop rdx
    pop rcx
    pop rbx
    pop rax

    ret  ; 返回并继续执行下一个进程

操作系统级别的进程切换

在实际操作系统中,进程切换涉及更多的复杂性,包括但不限于:

  • 中断处理:中断(如时钟中断)通常是触发进程切换的事件。
  • 内存保护和隔离:确保一个进程不能访问或修改另一个进程的内存。
  • 多处理器支持:在多核系统中,可能需要处理多处理器间的同步和上下文切换。

总结
进程切换是操作系统中的一个关键机制,它允许多个进程共享CPU资源。通过保存当前进程的状态并恢复下一个进程的状态,操作系统确保了进程的正确执行。虽然在底层实现上涉及复杂的硬件和软件交互,但其基本概念是保存和恢复进程的CPU状态及其内存管理信息。

  1. 虚拟内存:
    现代操作系统使用虚拟内存技术,为每个进程提供一个独立的虚拟地址空间。
    虚拟地址空间通常包括代码段、数据段、堆段和堆栈段等。
  2. 页表:
    每个进程都有自己的页表,用于映射虚拟地址到物理地址。
    页表确保进程只能访问自己的虚拟地址空间,堆栈段也被映射到独立的物理内存区域。
  3. 内存管理单元 (MMU):
    MMU 负责将虚拟地址转换为物理地址,并根据页表设置进行访问权限检查。
    操作系统内核设置每个进程的页表,并通过 MMU 确保进程间的内存隔离。
+----------------------+        +----------------------+
| 进程 A 的虚拟内存空间 |        | 进程 B 的虚拟内存空间 |
+----------------------+        +----------------------+
|                      |        |                      |
|   代码段 (text)      |        |   代码段 (text)      |
|                      |        |                      |
+----------------------+        +----------------------+
|                      |        |                      |
|   数据段 (data)      |        |   数据段 (data)      |
|                      |        |                      |
+----------------------+        +----------------------+
|                      |        |                      |
|   堆段 (heap)        |        |   堆段 (heap)        |
|                      |        |                      |
+----------------------+        +----------------------+
|                      |        |                      |
|   堆栈段 (stack)     |        |   堆栈段 (stack)     |
|                      |        |                      |
+----------------------+        +----------------------+
|   内核空间           |        |   内核空间           |
+----------------------+        +----------------------+

进程 A 的页表                 进程 B 的页表
+----------------------+        +----------------------+
| 代码段映射           |        | 代码段映射           |
+----------------------+        +----------------------+
| 数据段映射           |        | 数据段映射           |
+----------------------+        +----------------------+
| 堆段映射             |        | 堆段映射             |
+----------------------+        +----------------------+
| 堆栈段映射           |        | 堆栈段映射           |
+----------------------+        +----------------------+
| 内核空间映射         |        | 内核空间映射         |
+----------------------+        +----------------------+



进程 A 的上下文                       进程 B 的上下文
+----------------------+            +----------------------+
| 寄存器值             |            | 寄存器值             |
|   RSP -> 堆栈指针     |            |   RSP -> 堆栈指针     |
|   RIP -> 指令指针     |            |   RIP -> 指令指针     |
+----------------------+            +----------------------+
| 页表基地址           |            | 页表基地址           |
+----------------------+            +----------------------+

2. 页表

页表是存储在内存中的。页表是虚拟内存管理的一个关键组件,用于将虚拟地址映射到物理地址。操作系统和硬件协同工作,通过内存管理单元(MMU)使用页表来执行地址转换和内存保护。

页表的结构
页表通常是多级结构,以减少内存消耗并提高查找效率。以下是典型的 x86-64 架构的四级页表结构:

  1. 页全局目录 (PGD):
  • 最上级页表,包含指向页上级目录的指针。
  1. 页上级目录 (PUD):
  • 第二级页表,包含指向页中间目录的指针。
  1. 页中间目录 (PMD):
  • 第三级页表,包含指向页表的指针。
  1. 页表 (PT):
  • 最低级页表,包含指向实际物理页的指针。

每一级页表都包含页表项(PTE),每个页表项都指向下一级页表或实际物理内存页。

页表的存储和访问
页表本身存储在内存中。当进程运行时,CPU 使用内存管理单元(MMU)和控制寄存器来访问页表并进行地址转换。

CPU 寄存器

  • CR3 寄存器:在 x86-64 架构中,CR3 寄存器包含当前活动页表(通常是页全局目录)的物理地址。当进行上下文切换时,操作系统会更新 CR3 寄存器以指向新进程的页表。

地址转换过程

  1. 虚拟地址分解:虚拟地址分为多个部分,每个部分对应于页表结构中的不同级别。
  2. 查找页表项:MMU 使用虚拟地址的各个部分逐级查找页表项,从 PGD 开始,直到找到最终的物理页地址。
  3. 访问物理内存:根据最终找到的物理页地址,访问实际的物理内存。

示例:四级页表地址转换

假设虚拟地址为 0x123456789ABC:

  1. 虚拟地址分解:
  • PGD 索引:0x1
  • PUD 索引:0x2
  • PMD 索引:0x3
  • PT 索引:0x4
  • 页内偏移:0x56789ABC
  1. 地址转换:
  • 从 CR3 寄存器中获取 PGD 基地址。
  • 使用 PGD 索引查找 PUD 的基地址。
  • 使用 PUD 索引查找 PMD 的基地址。
  • 使用 PMD 索引查找 PT 的基地址。
  • 使用 PT 索引查找物理页的基地址。
  • 加上页内偏移,得到最终的物理地址。
+-----------------------+
|      CR3 寄存器       |
| (PGD 基地址)          |
+-----------+-----------+
            |
            v
+-----------------------+      +-----------------------+
| 页全局目录 (PGD)      |      | 页表项 (PTE)          |
| +------+ +----------+ |      | +------+ +----------+ |
| | 索引 | | PUD 基地址| |      | | 索引 | | 物理页地址 | |
| +------+ +----------+ |      | +------+ +----------+ |
+-----------------------+      +-----------------------+
            |
            v
+-----------------------+
| 页上级目录 (PUD)      |
| +------+ +----------+ |
| | 索引 | | PMD 基地址| |
| +------+ +----------+ |
+-----------------------+
            |
            v
+-----------------------+
| 页中间目录 (PMD)      |
| +------+ +----------+ |
| | 索引 | | PT 基地址 | |
| +------+ +----------+ |
+-----------------------+
            |
            v
+-----------------------+
| 页表 (PT)             |
| +------+ +----------+ |
| | 索引 | | 物理页地址 | |
| +------+ +----------+ |
+-----------------------+
            |
            v
+-----------------------+
| 物理页                |
| +-------------------+ |
| | 页内偏移           | |
| +-------------------+ |
+-----------------------+

3. 虚拟内存和物理内存的关系

程序的堆栈段、代码段、数据段等最终都映射到物理内存上。虚拟内存管理通过页表将进程的虚拟地址空间映射到实际的物理内存地址,从而实现进程对内存的访问。

虚拟内存是操作系统提供的一种抽象,使每个进程看起来拥有独立的、连续的内存地址空间。实际上,这些虚拟地址通过页表映射到物理内存中的不同位置。

虚拟内存的分段
典型的进程虚拟地址空间包括以下几个主要段:

  1. 代码段 (text segment):
  • 存储程序的可执行代码。
  • 映射到物理内存中的只读区域,通常由操作系统和加载器负责加载。
  1. 数据段 (data segment):
  • 存储全局变量和静态变量。
  • 包括已初始化数据段和未初始化数据段(BSS)。
  1. 堆段 (heap segment):
  • 用于动态内存分配,例如通过 malloc 等函数。
  • 堆段的大小可以在程序运行时动态增长。
  1. 堆栈段 (stack segment):
  • 用于函数调用和局部变量。
  • 堆栈从高地址向低地址增长。

每个进程有自己的页表,操作系统通过页表管理虚拟地址到物理地址的映射

页表 (Page Table):

  • 页表是存储在物理内存中的数据结构,每个进程有自己独立的页表。
  • 页表条目(PTE)包含虚拟页面和物理页面的映射信息,包括页的物理地址和访问权限。
进程的虚拟地址空间                    物理内存
+----------------------+            +----------------------+
| 高地址               |            |                      |
| ...                  |            |                      |
| 堆栈段 (stack)       |            |   物理页             |
| ...                  |            |   ...                |
|                      |            |   ...                |
| 堆段 (heap)          |            |                      |
| ...                  |            |   物理页             |
|                      |            |                      |
| 数据段 (data)        |            |   物理页             |
|                      |            |                      |
| 代码段 (text)        |            |   物理页             |
|                      |            |                      |
| 低地址               |            |                      |
+----------------------+            +----------------------+


虚拟地址空间                         页表                          物理内存
+-------------------+              +-------------+                +-----------------+
|                   |              | 页表项 (PTE) |                | 物理页框 (PF)   |
| 代码段 (text)     |              | +---------+ |                | +-------------+ |
| +0x0000           | --+--------> | | 物理地址 | |  +------->    | | 代码段       | |
|                   |   |          | +---------+ |  |             | +-------------+ |
+-------------------+   |          +-------------+  |             +-----------------+
|                   |   |                            |
| 数据段 (data)     |   |          +-------------+  |             +-----------------+
| +0x1000           | --+--------> | 页表项 (PTE) |  +------->    | 物理页框 (PF)   |
|                   |              | +---------+ |                | +-------------+ |
+-------------------+              | | 物理地址 | |                | | 数据段       | |
|                   |              | +---------+ |                | +-------------+ |
| 堆段 (heap)       |              +-------------+                +-----------------+
| +0x2000           |                                       |
|                   |                                       |      +-----------------+
+-------------------+              +-------------+          |      | 物理页框 (PF)   |
|                   |              | 页表项 (PTE) |          |      | +-------------+ |
| 堆栈段 (stack)    | --+--------> | +---------+ |  +------>+------>| | 堆栈段       | |
| +0x3000           |   |          | | 物理地址 | |                | +-------------+ |
|                   |   |          | +---------+ |                +-----------------+
|                   |   |          +-------------+
+-------------------+   |
                        |
+-------------------+   |
| 页表基地址       |    |
| +-------------+ |    |
| | CR3         | | --+
| +-------------+ |
+-------------------+

4. 银行家调度算法

(https://www.cnblogs.com/wkfvawl/p/11929508.html)[https://www.cnblogs.com/wkfvawl/p/11929508.html]

(1)系统在某一时刻的安全状态可能不唯一,但这不影响对系统安全性的判断。
(2)安全状态是非死锁状态,而不安全状态并不一定是死锁状态。即系统处于安全状态一定可以避免死锁,而系统处于不安全状态则仅仅可能进入死锁状态。

做题的时候,列好3个矩阵。max矩阵即进程最大需要的资源矩阵,allocation矩阵即已经分配给进程的资源矩阵,need矩阵进程目前还需要的资源矩阵。available即目前还拥有的资源数量。然后就是假设分配给某个进程资源后,是否存在一个安全序列。如果存在,则系统安全。不存在,则系统 可能死锁

5. 乐观锁和悲观锁

乐观锁和悲观锁是两种常见的并发控制机制,用于解决多线程或多进程环境下的资源竞争问题。它们在处理并发事务时的理念和实现方式有所不同。

  1. 悲观锁(Pessimistic Lock)
    悲观锁 的核心思想是对资源的并发访问持悲观态度,假设每次数据访问都会发生冲突,因此在操作数据前,必须先加锁,以防止其他事务对该数据进行修改。

特点:

  • 锁定资源: 在读取或修改数据之前,对数据加锁,确保其他事务在当前事务完成之前无法访问或修改该数据。
  • 实现方式: 通常通过数据库的行锁、表锁或分布式锁来实现。对于数据库操作,悲观锁的典型实现是 SELECT FOR UPDATE,这会在查询数据的同时对查询的行加上排他锁。
  • 适用场景: 适用于数据争用非常激烈的场景,如同一份数据被多个事务频繁修改,锁可以避免脏读、不可重复读、幻读等问题。

缺点:

  • 性能影响: 因为每次操作都要加锁和释放锁,可能会导致较高的性能开销,尤其是在锁定范围大、锁定时间长的情况下。
    可能产生死锁: 如果多个事务之间的锁定顺序不当,可能会发生死锁。
  1. 乐观锁(Optimistic Lock)
    乐观锁 的核心思想是对资源的并发访问持乐观态度,假设大部分情况下不会发生冲突,因此在修改数据时不加锁,但在提交修改时会进行冲突检测。

特点:

  • 无锁操作: 在读取数据时不加锁,允许其他事务并发修改数据,只有在提交时才进行冲突检测。
  • 实现方式: 通常通过版本号(Version)或时间戳来实现。每次读取数据时获取当前版本号或时间戳,更新时再比较。如果提交时版本号或时间戳未发生变化,说明没有其他事务修改过数据,可以提交修改;否则,操作会失败,需要重新读取数据并尝试更新。
  • 适用场景: 适用于数据争用不严重的场景,如大多数事务对同一份数据的修改概率较低的情况下,乐观锁的性能较高。

缺点:

  • 处理冲突的成本: 如果事务冲突频繁,乐观锁可能导致大量重试操作,影响系统性能。
  • 需要业务支持: 乐观锁通常需要业务逻辑配合,如设计表结构时引入版本号字段。

总结对比

  • 锁定策略:
    • 悲观锁:假定会发生冲突,主动加锁防止其他事务修改数据。
    • 乐观锁:假定不会发生冲突,不加锁,但在提交时检测冲突。
  • 适用场景:
    • 悲观锁:适用于并发写入频繁、数据冲突概率高的场景。
    • 乐观锁:适用于并发写入较少、冲突概率低的场景。
  • 性能影响:
    • 悲观锁:可能带来较高的性能开销,尤其在高并发环境中。
    • 乐观锁:在冲突较少的情况下性能更优,但在冲突频繁时重试会带来额外开销。

6. 系统调用与一般调用之间的区别

系统调用(system call)与一般函数调用(function call)在计算机系统中有着不同的作用和执行方式。以下是它们的主要区别:

1. 定义与作用

  • 系统调用
    • 定义:系统调用是操作系统提供的接口,通过它,用户程序可以请求操作系统执行特定的服务,例如文件操作、进程管理、内存分配、网络通信等。
    • 作用:系统调用使得用户空间的程序可以访问和使用操作系统内核提供的资源和服务,例如读取文件、发送网络请求、分配内存等。
  • 一般调用
    • 定义:一般函数调用是在程序内部或库中调用函数,不涉及操作系统内核,只在用户空间中执行。
    • 作用:一般函数调用主要用于程序内部的逻辑处理和功能实现,比如数学计算、字符串操作、数据处理等。

2. 执行环境

  • 系统调用
    • 执行环境:系统调用需要从用户空间切换到内核空间执行。用户程序通过系统调用将请求传递给操作系统内核,操作系统内核在内核态下执行这些操作。
    • 权限级别:系统调用执行在更高权限的内核态(Ring 0)下,因为它需要访问硬件资源和操作系统核心功能。
  • 一般调用
    • 执行环境:一般函数调用在用户空间执行,不涉及操作系统内核,不需要进行用户态到内核态的切换。
    • 权限级别:一般函数调用在用户态(Ring 3)下执行,只能访问用户空间的资源。

3. 开销

  • 系统调用
    • 开销:由于系统调用涉及从用户态到内核态的切换,这种上下文切换需要一定的时间和资源,因此系统调用的开销较大。
    • 原因:切换过程需要保存当前用户态的状态,进入内核态后执行操作,再切换回用户态,恢复状态。这一过程增加了执行时间。
  • 一般调用
    • 开销:一般函数调用在同一执行上下文中进行,不涉及上下文切换,开销较小。
    • 原因:函数调用只需要在当前栈上保存状态(如函数参数、返回地址),然后跳转到函数的执行代码,执行完毕后返回即可。

4. 使用场景

  • 系统调用
    • 使用场景:系统调用用于需要操作系统服务或资源的场景,如文件读写、进程创建与管理、网络通信等。
    • 示例read(), write(), fork(), execve(), open(), close() 等。
  • 一般调用
    • 使用场景:一般函数调用用于普通的程序逻辑处理,不涉及系统级资源管理。
    • 示例strlen(), printf(), sort(), math.sqrt() 等。

5. 错误处理

  • 系统调用
    • 错误处理:系统调用由于涉及硬件资源和操作系统服务,可能出现各种系统级错误,通常通过返回负值或设置 errno 来表示错误,需要特殊处理。
  • 一般调用
    • 错误处理:一般函数调用的错误通常是程序逻辑错误或输入错误,处理方式多样,视具体情况而定。

总结

  • 系统调用 是用户程序与操作系统之间的接口,用于请求操作系统服务,涉及用户态到内核态的切换,开销较大,但允许访问和操作系统资源。
  • 一般函数调用 仅在用户空间执行,用于程序内部的逻辑处理和功能实现,开销较小,不涉及操作系统内核。

理解系统调用和一般函数调用的区别,有助于开发者更好地优化程序性能,并正确选择和使用操作系统提供的服务。

系统调用可以嵌套,但需要理解嵌套调用的具体含义和实际情况。

1. 嵌套系统调用的概念

嵌套系统调用指的是在一个系统调用的执行过程中,另一个系统调用被调用。例如,系统调用 A 在执行过程中,又触发了系统调用 B。

2. 实际情况

  • 内核态的嵌套
    • 在某些复杂的操作中,操作系统内核本身可能在执行一个系统调用时,需要调用其他内核功能,这可能会触发其他系统调用。虽然从用户程序的角度看,这些调用是独立的,但内核可能会嵌套地执行它们。例如,在文件系统的实现中,打开文件(open())的系统调用可能会在内核态触发其他与文件系统相关的系统调用。
  • 用户态到内核态的嵌套
    • 在典型的操作系统设计中,用户态程序可以多次调用系统调用,而这些调用可能看起来是嵌套的。但从内核的角度看,每次调用都是一个独立的上下文切换,即从用户态切换到内核态,完成调用后再返回用户态。因此,虽然用户程序中可能出现看似嵌套的系统调用,但它们在内核中通常是依次处理的。

3. 嵌套调用的处理方式

  • 上下文切换:每次系统调用都会导致从用户态到内核态的上下文切换,即使是在一个系统调用内部调用另一个系统调用,这个过程也需要重新保存和恢复上下文。操作系统内核是设计来处理这样的场景的。
  • 重入与线程安全:大多数现代操作系统内核是重入的,这意味着多个系统调用可以同时在不同的上下文(例如不同的线程或进程)中执行,即使它们是嵌套调用。内核通过锁、原子操作等机制来确保这些调用的线程安全。

4. 示例

  • 实际示例:假设你在用户程序中执行 write() 系统调用,这个调用会在内核中进行文件写操作。如果这个文件是网络文件系统上的一个文件,那么在 write() 的过程中,内核可能会进行网络数据传输,这可能需要调用另一个与网络栈相关的系统调用。

7. 为什么页号最多占一页的容量

如果页表的大小超过了一页的容量,则无法通过一个页表索引直接访问完整的页表,这会导致分页机制失去简洁性和有效性。在这种情况下,内存管理变得复杂,页表需要额外的管理机制来处理其超过一页容量的部分,这样会降低分页系统的效率。

因此,页号对应的页表最多只能占一页的容量,这是为了确保分页系统的页表能够被有效地管理和分配。如果页表超过一页的容量,可能需要更复杂的多级页表或其他机制来处理,这违背了分页简化内存管理的初衷。

8.

计算机网络

1. 跨域的问题是出于浏览器的同源策略限制

跨域问题主要是由于浏览器的同源策略(Same-Origin Policy, SOP)导致的。同源策略是一种重要的安全机制,用来防止恶意网站通过脚本对另一个网站的数据进行未经授权的访问。

什么是同源策略?
同源策略规定,只有当两个 URL 具有相同的协议、域名和端口号时,它们才被认为是同源的。具体来说,同源策略的三要素是:

  • 协议:如 HTTP、HTTPS。
  • 域名:如 example.com。
  • 端口号:如 80(HTTP 默认端口)或 443(HTTPS 默认端口)。
    如果两个 URL 的协议、域名和端口号都相同,它们就是同源的,否则就是跨域的。

跨域问题的表现
当 Web 应用程序尝试从一个不同源的服务器请求资源时,浏览器会阻止这些请求,表现为跨域问题。例如,来自 https://example.com 的网页试图通过 JavaScript 发起一个到 https://api.anotherdomain.com 的请求,就会因为同源策略而被阻止。

常见的跨域操作包括:

  • AJAX 请求:使用 XMLHttpRequest 或 fetch API 向不同源的服务器发起请求。
  • 嵌入第三方资源:如在页面中嵌入来自不同源的 <iframe>、图片、脚本等。
  • 访问第三方的 cookies、localStorage 等。

解决跨域问题的方法
由于跨域问题是由同源策略引起的,因此解决跨域问题的核心是让浏览器信任不同源的请求。常见的解决方案包括:

  • CORS(Cross-Origin Resource Sharing):
    CORS 是一种机制,允许服务器通过设置特定的 HTTP 头,来告诉浏览器允许来自其他源的请求。常见的 CORS 头包括:
    • Access-Control-Allow-Origin: 指定允许访问资源的域名,可以是 * 表示允许所有域名访问。
    • Access-Control-Allow-Methods: 指定允许的 HTTP 方法,如 GET、POST 等。
    • Access-Control-Allow-Headers: 指定允许的 HTTP 请求头。
  • JSONP(JSON with Padding):
    JSONP 是一种跨域请求的早期解决方案,通常用于 GET 请求。它通过动态创建一个 <script> 标签,并将请求参数放在 URL 中。由于 <script> 标签不受同源策略的限制,因此可以绕过跨域问题。不过,JSONP 仅支持 GET 请求,且存在安全性问题,因此不推荐用于现代应用。
  • 代理服务器:
    可以通过在同源服务器上设置代理,将跨域请求转发到目标服务器。客户端向同源服务器发送请求,服务器再代为请求目标资源。这种方式能够很好地绕过浏览器的同源策略,但需要额外配置服务器。
  • 服务器端渲染:
    对于某些请求,可以在服务器端完成所有数据请求,并将结果直接渲染给客户端,从而避免浏览器端的跨域问题。

总结
跨域问题源于浏览器的同源策略,旨在保护用户免受跨站点脚本攻击。通过使用 CORS、JSONP、代理服务器等技术,可以解决跨域问题,允许安全的跨域数据访问。

只要不通过浏览器访问,就不会触发同源策略?

同源策略(Same-Origin Policy, SOP) 是一种专门针对浏览器的安全机制。因此,只有在通过浏览器进行访问时,同源策略才会生效,限制跨源请求。如果不通过浏览器访问,比如服务器端脚本、命令行工具、移动应用、后端服务之间的通信等,则不受同源策略的限制。

具体来说:

  1. 浏览器访问:
  • 当你在浏览器中访问网页,并且该网页中的脚本尝试从不同源获取资源时,浏览器的同源策略会生效,限制这些跨域请求。
  • 例如,使用 JavaScript 发起 AJAX 请求、嵌入跨域的 <iframe>、通过 JavaScript 访问 cookies 等,都会受到同源策略的限制。
    非浏览器环境:
  1. 服务器端代码:如在服务器端的 Python、Node.js、Java、PHP 等编程语言中发起的 HTTP 请求,不受同源策略的限制。服务器可以自由地与任何源通信,无需考虑同源策略。
  • 命令行工具:如 curl、wget 等工具可以用来发起 HTTP 请求,不受同源策略限制。
  • 移动应用:移动应用在请求 API 或资源时,也不受同源策略的限制,应用开发者可以自由地与不同源的服务器进行通信。

举个例子:

  • 浏览器环境:你在 https://example.com 上运行的 JavaScript 代码尝试向 https://api.anotherdomain.com 发起 AJAX 请求。由于不同源,浏览器的同源策略会阻止这个请求,除非目标服务器通过 CORS 头允许该请求。
  • 服务器环境:假设你在服务器端(如 Node.js)写了一段代码,通过 https.request 向 https://api.anotherdomain.com 发起请求。这时,这个请求不会受到同源策略的限制,可以直接进行。
  • 服务器通过其他安全措施来保护资源和数据的安全,例如身份验证、授权机制、防火墙等。服务器端的安全依赖于开发者对访问控制和数据保护的管理,而不是依赖于同源策略。

总结
同源策略只在浏览器环境中生效,用来保护用户的数据安全,防止跨站脚本攻击。而在非浏览器环境中,如服务器端代码、命令行工具、移动应用等,同源策略不适用,可以自由地发起跨源请求。因此,如果你的应用不涉及浏览器访问,就不需要考虑同源策略的限制。

2. 数据链路层中,哪种方法可以用于差错检测

  • 奇偶检验(Parity Check)
  • 循环冗余校验(Cyclic Redundancy Check, CRC)
  • 校验和(CheckSum)
  • 汉明码(Hamming Code)

数据结构

1. KMP算法

这个我觉得讲的最清楚。
(https://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html)[https://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html]

2. 二叉排序树

二叉排序树(Binary Search Tree, BST),也称为二叉查找树,是一种特殊的二叉树结构,它具有以下性质:

二叉排序树的性质

  • 节点的左子树:对于树中的任意一个节点,其左子树中所有节点的值都小于该节点的值。
  • 节点的右子树:对于树中的任意一个节点,其右子树中所有节点的值都大于该节点的值。
  • 每个子树都是二叉排序树:左子树和右子树本身也是二叉排序树,这使得整个树递归地满足二叉排序树的性质。

数据库

1. JOIN

我们可以通过两个简单的表来演示不同类型的 SQL JOIN。

假设有两个表 CustomersOrders

Customers 表:

CustomerID CustomerName
1 Alice
2 Bob
3 Charlie

Orders 表:

OrderID CustomerID Product
101 1 Book
102 2 Pen
103 4 Notebook
1. INNER JOIN 示例
SELECT Customers.CustomerID, Customers.CustomerName, Orders.Product
FROM Customers
INNER JOIN Orders ON Customers.CustomerID = Orders.CustomerID;

结果:

CustomerID CustomerName Product
1 Alice Book
2 Bob Pen

解释: INNER JOIN 只返回两个表中都有匹配行的记录。因为 CustomerID = 3 的 Charlie 没有对应的订单,CustomerID = 4 的订单也没有对应的客户,所以这些行不会出现在结果中。

2. LEFT JOIN 示例
SELECT Customers.CustomerID, Customers.CustomerName, Orders.Product
FROM Customers
LEFT JOIN Orders ON Customers.CustomerID = Orders.CustomerID;

结果:

CustomerID CustomerName Product
1 Alice Book
2 Bob Pen
3 Charlie NULL

解释: LEFT JOIN 返回左表中的所有记录,即使右表中没有匹配的记录。因为 Charlie 没有订单,所以他的 Product 列为 NULL

3. RIGHT JOIN 示例
SELECT Customers.CustomerID, Customers.CustomerName, Orders.Product
FROM Customers
RIGHT JOIN Orders ON Customers.CustomerID = Orders.CustomerID;

结果:

CustomerID CustomerName Product
1 Alice Book
2 Bob Pen
4 NULL Notebook

解释: RIGHT JOIN 返回右表中的所有记录,即使左表中没有匹配的记录。因为 OrderID = 4 的订单没有对应的客户,所以 CustomerName 列为 NULL

4. FULL JOIN 示例
SELECT Customers.CustomerID, Customers.CustomerName, Orders.Product
FROM Customers
FULL JOIN Orders ON Customers.CustomerID = Orders.CustomerID;

结果:

CustomerID CustomerName Product
1 Alice Book
2 Bob Pen
3 Charlie NULL
4 NULL Notebook

解释: FULL JOIN 返回两个表中的所有记录。对于没有匹配的记录,会显示 NULL。所以,Charlie 没有订单,Order 101 没有对应的客户,结果中都显示 NULL

2. 索引的作用

示例场景:

假设我们有一个名为 employees 的表,用于存储公司员工的信息,表结构如下:

CREATE TABLE employees (
    employee_id INT PRIMARY KEY,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    department VARCHAR(50),
    salary DECIMAL(10, 2)
);

其中,employee_id 是主键,代表每个员工的唯一标识。

没有索引的查询:

假设这个表里有一百万条记录,现在你想查询所有姓为 “Smith” 的员工:

SELECT * FROM employees WHERE last_name = 'Smith';

没有索引的情况下,数据库必须对整个 employees 表进行全表扫描(Full Table Scan),也就是从第一条记录一直到最后一条记录,逐条检查 last_name 是否等于 “Smith”。

  • 全表扫描的代价:对于一百万条记录,可能需要遍历所有的记录,时间复杂度为 O(n),查询速度会非常慢,特别是在大表中。

建立索引:

为了加速这个查询,我们可以在 last_name 列上创建一个索引:

CREATE INDEX idx_last_name ON employees(last_name);

索引创建之后,数据库会在后台构建一个数据结构(例如 B-树),以 last_name 作为键来排序和存储记录的位置指针。

有索引的查询:

再执行相同的查询:

SELECT * FROM employees WHERE last_name = 'Smith';

有索引的情况下,查询过程如下:

  1. 查询索引:数据库首先查询 idx_last_name 索引,利用索引的排序特性,快速定位到所有 last_name 为 “Smith” 的记录。对于 B-树结构,查找的时间复杂度为 O(log n)。
  2. 获取记录:找到符合条件的索引后,数据库通过索引中的指针直接访问对应的记录,而不需要扫描整个表。
  • 结果:通过索引,数据库可以极大地减少需要扫描的记录数,查询速度大幅提升,特别是在大表中。

索引的实际作用:

为了更好理解,我们假设 employees 表的 last_name 列包含以下示例数据:

| employee_id | first_name | last_name | department | salary  |
|-------------|------------|-----------|------------|---------|
| 1           | John       | Smith     | HR         | 50000.00|
| 2           | Alice      | Johnson   | IT         | 60000.00|
| 3           | Bob        | Smith     | Finance    | 55000.00|
| ...         | ...        | ...       | ...        | ...     |
  • 没有索引时:数据库从头开始扫描每一条记录,即使找到了匹配的记录(如 John Smith),它仍然必须继续扫描所有记录以确保找到所有匹配项。
  • 有索引时:数据库直接跳转到 Smith 这个位置开始检索,并且因为索引是排序的,能够快速定位所有 Smith 的记录。

其他类型的索引:

1. 主键索引

  • 自动创建:当你为表创建一个主键时,数据库会自动为这个主键创建一个唯一索引。例如,employee_id 列上默认有主键索引,所有基于 employee_id 的查询都会非常快。

2. 联合索引(复合索引)

  • 示例

    :如果经常查询包含

    department

    last_name

    的条件,可以创建一个联合索引:

    CREATE INDEX idx_dept_last_name ON employees(department, last_name);
  • 效果

    :这个索引会优化查询,例如:

    SELECT * FROM employees WHERE department = 'IT' AND last_name = 'Smith';
  • 原理:数据库会先根据 department 查找,再根据 last_name 进一步过滤,查询效率更高。

索引的代价:

虽然索引提高了查询效率,但也有一定的代价:

  1. 存储开销:索引占用额外的存储空间。如果表中的数据量非常大,索引也会占用大量磁盘空间。
  2. 维护开销:在插入、更新或删除记录时,数据库不仅需要修改表中的数据,还需要更新相关的索引。这会增加写操作的时间成本。

总结:

  • 索引的优势:主要在于提高查询速度,特别是在大数据集上,可以显著减少查询时间。
  • 索引的选择:应基于查询的频率和类型来决定在哪些列上建立索引。通常,在经常用于查询条件的列(如 WHEREJOIN 等操作的列)上建立索引,可以带来明显的性能提升。

通过这个例子,可以清楚地看到索引在提高数据库查询效率方面的作用,以及它的工作原理。

3. 一条查询语句在MySql服务端的执行过程

20240818100418

4. TPS Transactions Per Second

在数据库系统中,TPS 是 Transactions Per Second 的缩写,指的是系统每秒能够处理的事务数。它是衡量数据库系统性能的重要指标,特别是在高并发场景下。

什么是事务(Transaction)?
事务是数据库操作的一个逻辑单元,它通常包括一组数据库操作(如读取、写入、更新、删除),这些操作要么全部成功,要么全部失败。事务通常遵循 ACID 特性:

  • Atomicity(原子性): 事务的所有操作要么全部完成,要么全部回滚。
  • Consistency(一致性): 事务完成后,数据库必须从一个一致性状态转换到另一个一致性状态。
  • Isolation(隔离性): 事务之间互不干扰,一个事务的执行不会影响其他事务。
  • Durability(持久性): 事务一旦提交,数据的变更是永久性的,即使系统崩溃也不会丢失。

TPS 的含义:

  • TPS 表示每秒可以执行的事务数量,通常用于评估数据库系统在高并发环境下的性能。
  • TPS 值越高,意味着系统在相同时间内能够处理的事务数量越多,表明系统的处理能力更强。
  • TPS 是数据库性能测试中的关键指标,通常与系统的响应时间、延迟、吞吐量等指标一起使用,全面反映数据库的运行效率。

5.

6.

7.

Linux系统使用

1. 文件权限

在 Linux 和类 Unix 操作系统中,每个文件和目录都有一组权限,用来控制谁可以读取、写入或执行该文件或目录。这些权限可以帮助系统管理员确保只有授权的用户能够访问或修改系统中的文件和目录。

文件权限的基本概念

文件权限分为三种类型,分别对应三类用户:

  1. 用户(User / Owner): 文件的所有者。
  2. 组(Group): 拥有访问权限的用户组,文件所有者可以指定文件所属的用户组。
  3. 其他用户(Others): 既不属于文件所有者,也不属于文件所属的组的所有其他用户。

权限类型

对于每种用户类型,有三种基本权限:

  1. 读取(Read, r: 允许查看文件内容或列出目录内容。
  2. 写入(Write, w: 允许修改文件内容或在目录中创建、删除文件。
  3. 执行(Execute, x: 允许执行文件(如果是可执行程序或脚本),或者进入目录。

权限表示法

权限可以通过两种方式表示:符号表示法和八进制表示法。

1. 符号表示法

权限通常用 r, w, x 来表示,例如:

  • ```
    rwxr-xr–
    
      - `rwx`: 用户(Owner)拥有读取、写入和执行权限。
      - `r-x`: 组(Group)拥有读取和执行权限。
      - `r--`: 其他用户(Others)只有读取权限。
    
    这个表示法分为三组,每组三个字符,分别对应用户、组和其他用户的权限。
    
    ##### 2. 八进制表示法
    
    权限也可以用一个三位的八进制数来表示,其中每位代表一组权限:
    
    - `4` 代表 `r` (读取权限)
    - `2` 代表 `w` (写入权限)
    - `1` 代表 `x` (执行权限)
    
    例如,`rwxr-xr--` 可以用八进制表示为 `755`:
    
    - 用户(Owner):`rwx` = 4 + 2 + 1 = 7
    - 组(Group):`r-x` = 4 + 0 + 1 = 5
    - 其他用户(Others):`r--` = 4 + 0 + 0 = 4
    
    所以 `rwxr-xr--` 对应的八进制权限是 `755`。
    
    **查找某个目录下没有执行权限的js文件**
    
    ```bash
    find /path/to/directory -name "*.js" ! -perm /u=x

2. 限制某个用户使用cpu的时长

ulimit -t 3600

3. 显示test.c中包含main的行

grep 'main' test.c

4.

代码

1. pipe管道

buf[n] = "\0"; 这里要注意,你写入 buf[n]的数据是一个指向 "\0"的指针。而不是你想要的 \0,所以在标准输入台输出的时候,会显示乱码。需要换成 buf[n] = '\0'这样才能算作是字符串的结束标志。

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main(){
    int pipe_fd[2];
    pid_t pid;
    char buf[1024];
    if (pipe(pipe_fd) == -1){
        perror("pipe");
        return 1;
    }
    pid = fork();
    if (pid < 0){
        perror("fork");
        return 1;
    }
    if(pid == 0){//子进程
        close(pipe_fd[1]);//close write of pipe_fd
        int n = read(pipe_fd[0], buf, sizeof(buf)-1);
        if (n > 0){
            buf[n] = "\0"; // 这里要注意
            printf("subprocess read data:%s\n",buf);
        }
        close(pipe_fd[0]);
    }else{
        close(pipe_fd[0]); // close read of pipe_fd
        const char* msg = "Hello from parent process!";
        write(pipe_fd[1], msg, strlen(msg));
        close(pipe_fd[1]);
        wait(NULL);
    }
    return 0;

}

2. python多线程

问题描述:同学遇到了一个问题,即在开启多线程处理数据时,并使用 tqdm.tqdm(p.imap_unordered(worker, t_args), total=len(t_args))进行统计时,总是会在最后卡住。

原始代码:

import multiprocessing
import time
import os
import tqdm
import signal

def sing(item):
    singer = item[0]
    song = item[1]
    if singer%5==0 and singer!=0:
        os.kill(os.getpid(), signal.SIGKILL)
    print('running ,',str(singer),' pid: ',os.getpid())

if __name__ == '__main__':
    t_args = [(i,1) for i in range(30)]
    print(os.getpid())
    print(os.cpu_count())
    p = multiprocessing.Pool(processes=None, maxtasksperchild=5)
    for _ in tqdm.tqdm(p.imap_unordered(sing, t_args), total=len(t_args)):
        pass
    # p.imap_unordered(sing, t_args)
    p.close()
    p.join()

这里无论是否使用tqdm统计p.imap_unorder的完成,都会卡住。

原因分析

在sing函数中使用 os.kill(os.getpid(), signal.SIGKILL)结束子进程时,子进程直接结束了。父进程的进程池无法收到子进程结束的状态,所以他会一直等待子进程返回状态,但是子进程已经结束了。进程池需要通过 SIGCHLD 信号了解子进程的终止状态。如果子进程被强制终止(如 SIGKILL 信号–),进程池不会收到 SIGCHLD 信号,因此会一直等待子进程完成。

当进程正常退出时,当一个进程正常退出时(例如,通过 exit()return),它会向其父进程发送 SIGCHLD 信号。父进程(在这种情况下是进程池)会捕获到这个信号,并更新其内部状态以反映子进程已经终止。而 SIGKILL 是一个无法被捕获或忽略的信号。它会立即终止进程而不进行任何清理工作,也不会向父进程发送终止通知。因此,进程池无法知道子进程已经终止,可能会导致资源泄漏或进程池状态不一致。

所以我们需要在子进程中加入异常信号处理函数,在子进程接受到信号量以后,发送相应的信号量到父进程。

正常版本

import multiprocessing
import time
import os
import tqdm
import signal
# 进程使用以及传参
def sing(item):
    singer = item[0]
    song = item[1]
    if singer%5==0 and singer!=0:
        print(' pid: ',os.getpid())
        os.kill(os.getpid(), signal.SIGTERM)
    # print('running ,',str(singer),' pid: ',os.getpid(),'pgid: ', os.getpgid(os.getpid()))
    # print('running ,',str(singer),' pid: ',os.getpid())
    # print('sing:',os.getppid(), multiprocessing.current_process().name)
    print(f'{singer}{song}')


def handle_signal(signum, frame):
    print(f'Process {os.getpid()} received signal {signum}')
    raise SystemExit('Terminated')
  
def worker(item):
    signal.signal(signal.SIGTERM, handle_signal) # 创建信号处理函数,这样通过os向子进程发送信号以后,子进程可以向父进程发送信号。
    try:
        sing(item)
    except Exception as e:
        print(f'Unexpected error in process {os.getpid()}: {e}')      

if __name__ == '__main__':
    t_args = [(i,1) for i in range(30)]
    print(os.getpid())
    print(os.cpu_count())
    p = multiprocessing.Pool(processes=None, maxtasksperchild=5)
    for _ in tqdm.tqdm(p.imap_unordered(worker, t_args), total=len(t_args)):
        pass
    # p.imap_unordered(sing, t_args)
    p.close()
    p.join()

输出

可以观察到,子进程的 try/catch语句并没有打印相应的报错信息。但是信号处理函数中的打印语句是打印出来的。

186677
12
  0%|                                                                                                        | 0/30 [00:00<?, ?it/s]0唱1
1唱1
3唱1
2唱1
 pid:  186678
Process 186678 received signal 15
Process 186678 exiting: Terminated
4唱1
6唱1
7唱1
8唱1
9唱1
 pid:  186687
12唱1
Process 186687 received signal 15
Process 186687 exiting: Terminated
11唱1
13唱1
 pid:  186688
Process 186688 received signal 15
Process 186688 exiting: Terminated
14唱1
16唱1
18唱1
17唱1
19唱1
21唱1
 pid:  186686
24唱1
Process 186686 received signal 15
Process 186686 exiting: Terminated
23唱1
22唱1
26唱1
27唱1
 pid:  186682
Process 186682 received signal 15
28唱1
Process 186682 exiting: Terminated
29唱1
100%|█████████████████████████████████████████████████████████████████████████████████████████████| 30/30 [00:00<00:00, 3469.43it/s]

分析

在代码中,当子进程接收到 SIGTERM 信号时,信号处理函数 handle_signal 会立即执行,通常会中断当前正在进行的操作。这意味着信号处理函数的执行优先级是高于 try/catch 块中的代码的。所以当 handle_signal引发异常以后,子进程中 try/catch语句中的报错是无法打印出来的。

相关知识

1. 进程id pid、父进程id ppid,进程组id pgid之间的关系
  1. 进程ID (PID):
  • 每个进程在创建时会被分配一个唯一的进程ID。
  1. 父进程ID (PPID):
  • 每个进程都有一个父进程,父进程的进程ID称为父进程ID (PPID)。
  1. 进程组ID (PGID):
  • 默认情况下,进程组ID等于创建它的父进程的进程ID。即使一个进程创建了新的进程,这些新进程将继承父进程的进程组ID。
  • 进程组允许将多个相关的进程组织在一起,通常由第一个创建进程的PID作为PGID。
import os
import multiprocessing

def worker():
    pid = os.getpid()
    ppid = os.getppid()
    pgid = os.getpgid(pid)
    print(f"Process ID (PID): {pid}")
    print(f"Parent Process ID (PPID): {ppid}")
    print(f"Process Group ID (PGID): {pgid}")

if __name__ == '__main__':
    # 创建一个新的进程
    p = multiprocessing.Process(target=worker)
    p.start()
    p.join()

    # 主进程的信息
    print(f"Main Process ID (PID): {os.getpid()}")
    print(f"Main Process Group ID (PGID): {os.getpgid(os.getpid())}")

输出为

Main Process ID (PID): 178045
Main Process Group ID (PGID): 178045
Process ID (PID): 178046
Parent Process ID (PPID): 178045
Process Group ID (PGID): 178045

在这个例子中:

  • 主进程的PID是 178045,并且它的PGID也是 178045。
  • 当主进程创建了一个新的子进程时,子进程的PID变成了 178046。
  • 子进程的PPID是创建它的主进程的PID,即 178045。
  • 默认情况下,子进程继承了父进程的PGID,所以子进程的PGID仍然是 178045。
2. os.kill发送信号

使用 os.kill 函数向某个进程发送信号,实际上是由操作系统的内核负责产生信号并将其发送到指定进程。调用 os.kill 向进程发送信号时,如果进程注册了信号处理函数,该函数会优先执行。如果处理函数未终止进程,默认的信号处理行为将不会执行,进程会继续运行。这确保了用户可以通过信号处理函数实现自定义行为,而不是立即终止进程或触发其他默认行为。

信号的产生和发送过程
  1. 调用 os.kill 函数
    • 当你在代码中调用 os.kill(pid, signal.SIGTERM) 时,Python 会将这个请求传递给操作系统的内核。这里,pid 是目标进程的进程ID,signal.SIGTERM 是要发送的信号。
  2. 内核处理信号请求
    • 操作系统的内核接收到 os.kill 的请求后,会验证请求的合法性,包括检查发送信号的进程是否有权限向目标进程发送该信号。
    • 如果请求合法,内核会产生对应的信号。
  3. 信号的传递
    • 内核将生成的信号传递给目标进程。信号是一种异步通知机制,可以中断进程的正常执行,并引发预定义的信号处理程序。
  4. 信号的处理
    • 目标进程接收到信号后,如果注册了相应的信号处理程序(如使用 signal.signal(signal.SIGTERM, handle_signal) 注册的处理函数),处理程序会被调用。
    • 如果没有注册处理程序,默认的信号处理行为将被执行。对于 SIGTERM,默认行为是终止进程。
3. 信号处理函数、try/except 语句和系统默认行为的优先级关系

信号处理函数

  • 当进程接收到信号时,如果该信号有用户定义的处理函数,操作系统会立即调用这个处理函数。
  • 信号处理函数的调用是异步的,可以中断进程正在执行的任何代码,包括 try 块中的代码。
  • 信号处理函数具有最高优先级,因为它可以在任何时候中断进程的执行。

try/except 语句

  • try/except 块用于捕获和处理异常。在信号处理函数中引发的异常可以被 try/except 块捕获。
  • 当信号处理函数引发异常时,这个异常会中断当前代码的执行,并跳转到最近的 try/except 块。
  • try/except 块的优先级低于信号处理函数,但高于默认信号处理行为。

系统默认行为

  • 如果进程没有定义信号处理函数,或者信号处理函数没有引发异常,操作系统会执行该信号的默认处理行为。
  • 默认处理行为的优先级最低,只有在没有用户定义的信号处理函数或信号处理函数没有覆盖默认行为时才会执行。
4. 为什么try/except语句引发的异常,进程池无法接受到?

让我们深入探讨为什么进程池可以正确处理通过信号处理函数引发的 SystemExit 异常,但通过 try/except 语句引发的 SystemExit 异常却不能正确处理。

3. 信号处理函数和 try/except 语句的差异

  1. 信号处理函数的行为
    • 当一个进程接收到信号(如 SIGTERM)时,操作系统会立即调用相应的信号处理函数。这中断了当前进程的执行,并处理信号。
    • 在信号处理函数中引发 SystemExit 异常,会立即导致进程退出,并且操作系统会通知父进程(即进程池)该子进程已终止。
    • 这种行为是操作系统级别的处理,确保了父进程能够捕获到子进程的终止状态。
  2. try/except 语句的行为
    • try/except 语句是在用户代码层面进行异常处理。当在 except 块中引发 SystemExit 异常时,Python 解释器会认为这是一个正常退出过程。
    • 但在某些情况下,尤其是在多进程环境下,这种由用户代码引发的 SystemExit 可能不会被进程池正确感知到。这可能与 Python 多进程模块内部的异常传播机制有关。

文章作者: 美食家李老叭
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 美食家李老叭 !
评论
  目录