linux操作系统大致可分为进程管理、进程协调、内存管理、文件系统、网络管理等五大部分。本文讲述进程管理和进程协调。

进程管理

进程管理的目的是将用户和内核创建的进程/程序调度到cpu上执行,这些进程有三种

  1. 用户程序创建的一次性运行完的进程
  2. systemd等维护了常驻后台的后台进程,例如nginx,sshd
  3. 操作系统内核进程,例如kworker处理硬中断、信号、IO操作(包括cpu时间片硬中断调度、软中断抢占),kswapd管理页缓存和内存淘汰swap空间,watchdog检查内核死锁、防止系统卡死,ksoftirqd处理软中断,kblockd处理块设备IO,系统空闲线程(idle 线程)。这些进程一般定时执行。

为什么cpu要执行idle线程?因为CPU需要执行指令才能保持正常运行。CPU按照“取指令(Fetch)—解码(Decode)—执行(Execute)”的周期运行。如果没有指令可以取,或者取到的指令无法正确解码,CPU无法正常工作。程序计数器(PC)用于指示下一条需要执行的指令地址。如果没有明确的任务,PC可能指向无效地址,导致不可预测的行为。

进程队列

进程管理器需要把已经创建的进程放入队列中, 进程排队接受多核cpu的处理。

进程调度器有很多设计值得学习的,例如

  1. 就绪任务采用优先级队列组织,支持任务公平调度和任务抢占
  2. 存在等待队列存放需要等待较长时间,需要唤醒的任务
  3. 停止队列支持任务暂停和断点继续执行
  4. 任务执行前创建结构,执行完销毁结构

就绪队列(Ready Queue)

就绪队列是一个优先级队列

新创建的进程会放入就绪队列Ready Queue,等待队列是进程调度的对象,将这些进程调度给CPU执行。Linux使用 CFS调度器(完全公平调度器) 管理就绪队列。CFS调度器通过一个红黑树(rb_tree)来组织进程,就绪队列中的每个进程以其虚拟运行时间作为排序依据。

调度器从红黑树中选择虚拟运行时间最小的进程进行执行。当进程时间片用完,或被抢占,进程更新虚拟运行时间后重新插入到就绪队列

就绪队列中进程的状态是R (TASK_RUNNING)

等待队列(Wait Queue)

进程在等待某些事件(如IO完成、信号到达、锁释放)时进入该队列。对应进程S状态和D状态

进程S状态TASK_INTERRUPTIBLE,可中断的睡眠状态。例如等待锁,等待socket事件。该状态进程可被调度器调度。

D状态TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。该状态进程无法响应信号,无法被调度,只能等待IO执行完毕(但可以响应硬中断)。例如进程等待磁盘IO期间无法中断,原因1. 进程没必要被中断,数据到不了中断也处理不了什么 2. 简化实现,防止IO读写没有进程处理导致问题 3. 避免由于磁盘等故障导致大量进程持续中断。

对于持有spinlock的进程需要屏蔽中断,但这时候的进程不是等待状态。

停止队列(Stopped Queue)

进程被暂停(例如接收到SIGSTOP信号)后进入该队列,例如ptrace机制调试进程时会暂停某些进程,放入停止队列中。

停止队列中的进程不会被调度器调度,直到接收到恢复信号(如SIGCONT)。

僵尸队列(Zombie Queue)

包含所有处于TASK_ZOMBIE状态的进程。僵尸进程是已结束执行但尚未被其父进程回收的进程。

父进程调用wait族函数等待子进程执行完并回收时,僵尸进程会被从队列中移除。

TASK_DEAD状态的进程会被系统回收所有资源,释放进程控制块(PCB)。

进程优先级

实时进程优先级:范围从 1 到 99(高优先级)。实时进程调度时,高优先级的进程必须执行完毕或主动放弃 CPU,低优先级的进程才能运行。

普通进程优先级:范围从 100 到 139(低优先级)。默认优先级为 120。普通进程使用 完全公平调度器(CFS),CFS中,执行时间少的进程在计算时会有较高的动态优先级。

普通进程优先级

可以通过 nice 或 renice 命令设置普通进程的静态优先级。范围:-20(最高优先级)到 19(最低优先级)。nice默认值为 0。
nice 值越低,进程优先级越高。

内核线程是由内核创建并运行的任务,通常用于关键系统功能,优先级由内核自行分配。

1
2
3
4
5
进程类别	优先级范围	优先级特点
kswapd 高 内存页面交换线程,优先级较高
kworker 高 内核工作队列处理线程
khugepaged 中 大页面分配优化
jbd2 中 日志缓冲区管理线程

系统服务和守护进程通常负责后台任务,默认优先级设置中等,以确保它们不会过多占用 CPU,但仍能及时响应。

1
2
3
4
5
6
7
进程类别	默认 Nice 值	优先级特点
init/systemd 0 系统初始化进程,优先级适中
cron 0 定时任务调度器,重要但无需实时响应
sshd 0 远程登录服务,优先级适中
udevd 0 设备管理服务,实时性要求中等
NetworkManager 0 网络管理,重要性较高
rsyslogd 0 日志服务,优先级适中

完全公平调度CFS

  1. 操作系统统计周期内每个进程的执行时间,这个执行时间是个虚拟执行时间vriture_runtime,计算时会考虑进程优先级,优先级高的进程统计的执行时间会偏少。
  2. 执行时间少的进程在下个周期优先执行

进程切换,Linux内核使用定时器硬中断来实现时间片轮转调度。当定时器中断发生时,内核会检查当前运行的进程是否超过其时间片。是则进行进程切换。定时器中断的频率通常为100Hz或1000Hz,表示每隔10ms或1ms中断一次。

进程协调

进程协调的手段包括两个部分

  1. 协调进程切换,包括信号、硬/软中断、系统调用
  2. 协调进程/线程同步,包括锁、条件变量、信号量等

协调进程切换

系统调用

当进程从执行用户代码转向执行内核代码时,就发生系统调用。需要注意该过程不发生进程切换。

  1. 进入内核代码前,进程首先调用int $0x80syscall指令,该指令会让CPU转向执行(entry_INT80_32或entry_SYSCALL_64)汇编代码,即根据系统调⽤号调⽤对应的内核处理函数。
  2. 执行内核处理函数前,cpu 将当前寄存器内容保存到用户栈,使用内核栈执行内核处理函数
  3. 内核处理函数执行完后,调用sysret 指令从用户栈恢复寄存器内容,使用用户栈继续执行用户程序

为什么执行内核代码需要调用int $0x80或syscall指令,而不是直接函数调用的形式执行?
因为系统调用状态的保存是CPU硬件实现的,CPU的两个寄存器

MSR_LSTAR:保存内核系统调用入口的地址。
MSR_STAR:保存用户态和内核态的代码段选择器。

在syscall指令中,寄存器保存用户栈,执行内核函数,恢复用户栈都是硬件实现的。只需要调用CPU指令。操作系统只需要配置相关寄存器然后调用指令即可。

int $0x80中,寄存器保存用户栈,执行内核函数,恢复用户栈是操作系统软件实现的(性能差于syscall,现代CPU基本使用后者),操作系统把处理函数注册到中断向量表IDT的0x80位置。当进程执行int $0x80指令,CPU会自动从寄存器找到IDT地址,转向执行处理函数(IDT的地址存放在寄存器IDT 寄存器(IDTR))。“CPU从寄存器找到IDT,转向执行处理函数”这是硬件实现的。

硬中断和中断向量表

上面说到,系统调用可以通过发起int $0x80指令,CPU自动从中断向量表找到处理函数。中断向量表IDT是中断执行的核心,中断函数由操作系统注册,但无法直接执行,只能借助cpu中断表执行。中断优先级与向量号直接相关,向量号越低优先级越高。

硬中断和中断向量表,中断的对象是CPU。通过CPU引脚信号/CPU指令来触发中断。硬中断可以抢占CPU,一般来说,如果用户态进程发生了硬中断/异常,执行硬中断处理函数的进程和用户态进程是一个。如果硬中断不是由用户态进程产生,例如时间片用尽、磁盘网络IO中断,中断处理函数由kworker 执行。

1
2
3
4
5
6
7
/*
* Vectors 0 ... 31 : system traps and exceptions - hardcoded events
* Vectors 32 ... 127 : device interrupts
* Vector 128 : legacy int80 syscall interface
* Vectors 129 ... INVALIDATE_TLB_VECTOR_START-1 except 204 : device interrupts
* Vectors INVALIDATE_TLB_VECTOR_START ... 255 : special interrupts
*/

0-31中保存的是异常的中断向量,这些异常包括除0、断点调试、边界溢出、段错误、浮点数异常等,中断处理会转向执行查找进程对应的异常处理函数,默认是异常退出进程(向进程发SIGINT信号,程序异常退出)

为什么使用中断处理异常?因为异常是运行态的,程序编译后并不知道异常会不会执行。当CPU执行遇到异常后,不能继续执行原程序的指令。 只能去IDT执行异常函数的处理指令。

CPU转向执行异常,需要1. 停止当前指令执行; 2. 保存 CPU 状态; 3. 跳转到指定的处理程序处理。

CPU/进程上下文切换也是通过硬中断实现,同样需要1. 停止当前进程指令执行; 2. 保存 CPU 状态; 3. 跳转转型下一个进程的指令。CPU/进程上下文是否切换 等价于 是否出现硬中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* Interrupts/Exceptions */
enum {
X86_TRAP_DE = 0, /* 0, Divide-by-zero */
X86_TRAP_DB, /* 1, Debug */
X86_TRAP_NMI, /* 2, Non-maskable Interrupt */
X86_TRAP_BP, /* 3, Breakpoint */
X86_TRAP_OF, /* 4, Overflow */
X86_TRAP_BR, /* 5, Bound Range Exceeded */
X86_TRAP_UD, /* 6, Invalid Opcode */
X86_TRAP_NM, /* 7, Device Not Available */
X86_TRAP_DF, /* 8, Double Fault */
X86_TRAP_OLD_MF, /* 9, Coprocessor Segment Overrun */
X86_TRAP_TS, /* 10, Invalid TSS */
X86_TRAP_NP, /* 11, Segment Not Present */
X86_TRAP_SS, /* 12, Stack Segment Fault */
X86_TRAP_GP, /* 13, General Protection Fault */
X86_TRAP_PF, /* 14, Page Fault */
X86_TRAP_SPURIOUS, /* 15, Spurious Interrupt */
X86_TRAP_MF, /* 16, x87 Floating-Point Exception */
X86_TRAP_AC, /* 17, Alignment Check */
X86_TRAP_MC, /* 18, Machine Check */
X86_TRAP_XF, /* 19, SIMD Floating-Point Exception */
X86_TRAP_IRET = 32, /* 32, IRET Exception */
};

向量 32 到 127,分配给硬件中断和其他系统用途。硬件中断的触发,连接cpu引脚的硬件会向引脚发送信号,cpu收到信号会立即暂停当前程序处理(屏蔽中断的除外),从idt中找到硬件中断处理函数执行。

对需要较长时间处理的中断,例如网卡磁盘收发数据。为了时效性,硬件中断处理函数只是创建一个异步任务到队列就返回,任务具体的处理由后续软中断负责。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
IRQ	    中断号	用途
IRQ 0 32 系统定时器(时钟中断)
IRQ 1 33 键盘
IRQ 2 34 可编程中断控制器级联
IRQ 3 35 串口 2
IRQ 4 36 串口 1
IRQ 5 37 并口 2 / 声卡
IRQ 6 38 软盘控制器
IRQ 7 39 并口 1
IRQ 8 40 CMOS 实时时钟
IRQ 9 41 可用(通常用于 ACPI)
IRQ 10 42 可用
IRQ 11 43 可用
IRQ 12 44 PS/2 鼠标
IRQ 13 45 协处理器 / FPU
IRQ 14 46 主 IDE 控制器
IRQ 15 47 从 IDE 控制器

向量 128(0x80),用于系统调用(如 int $0x80)。

向量 129 到 255,用户自定义的硬中断。例如,int 0x81:某些调试工具可能使用该中断来与内核交互;int 0xFF:某些实时系统可能定义为特殊的系统功能调用;APIC 定时器通常分配到高向量。

软中断

软中断和软中断向量表,是内核软件层实现的(软中断号,处理函数)的映射,目的是异步任务延迟调度,没有直接影响到CPU,不抢占CPU。软中断和是否出现CPU/进程上下文切换没有联系。

软中断通过内核代码调用 raise_softirq() 触发。主要可以分为1. 定时器(很重要,比如处理超时锁等待,sleep超时) 2. 网络发送接收 3. 块设备IO 4. Tasklet 等类型。Tasklet是用户注册的一种函数,该函数和软中断一起被执行。

每个cpu维护一个软中断队列,软中断的执行不会切换CPU。如果CPU软中断占用时间长,可能是网络包是大量的小包,也可能是磁盘/网络处理慢。

1
2
3
4
5
6
7
8
9
10
11
软中断号	名称	用途
0 HI_SOFTIRQ 高优先级任务,通常用于调度紧急操作。
1 TIMER_SOFTIRQ 定时器中断,用于触发周期性任务,如更新系统时间,超时唤醒等待的进程。
2 NET_TX_SOFTIRQ 网络发送中断,用于网络数据的发送操作。
3 NET_RX_SOFTIRQ 网络接收中断,用于处理接收到的网络数据包。
4 BLOCK_SOFTIRQ 块设备中断,用于处理块设备的异步 I/O 操作。
5 IRQ_POLL_SOFTIRQ 中断轮询,用于处理某些设备的中断轮询机制。
6 TASKLET_SOFTIRQ Tasklet 中断,用于调度较低优先级的任务。
7 SCHED_SOFTIRQ 调度中断,用于进程调度器的运行队列更新。
8 HRTIMER_SOFTIRQ 高精度定时器中断,用于处理精确的定时器任务。
9 RCU_SOFTIRQ RCU(Read-Copy-Update)机制,用于内存管理。

软中断的处理时机

  1. 硬中断处理完成后,内核会检查是否有挂起的软中断。如果有则执行
  2. 在某些内核路径中,内核会主动调用 do_softirq() 来检查和执行挂起的软中断。例如网络协议栈处理数据包时,通过 net_rx_action() 调用软中断;高精度定时器中,触发 hrtimer 相关软中断。

软中断的优先级高于普通任务。当软中断执行时间较长、内核会将剩余未处理的软中断交给专用线程 ksoftirqd 来执行。

信号

软中断和信号的区别是,软中断是CPU级别的,软中断得到的网络包、磁盘数据包需要另外拷贝到进程空间才能被进程使用。而信号是针对进程的。也就是说,操作系统为每个CPU维护一个软中断任务队列,但每个进程都维护了自己的信号任务队列。

信号执行的时机

  1. 进程从系统态返回到用户态的前夕,也就是一个用户态的进程由于系统调用、硬中断(进程切换)或异常而进入系统空间,执行完后返回用户态前,处理信号
  2. 进程在进入等待队列后刚被唤醒的时候

信号可以由内核发起(内核想影响某进程的执行,主要是通知某进程终止)就给该进程发个信号,虽然都是通知进程退出,但退出原因不同。退出原因可以是终端中断,中断退出,杀死,运算错误,段错误等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#define SIGHUP 1	终端挂起或控制进程终止
#define SIGINT 2 终端中断(Ctrl+C 组合键)
#define SIGQUIT 3 终端退出(Ctrl+\组合键)
#define SIGILL 4 非法指令
#define SIGTRAP 5 debug 使用,有断点指令产生
#define SIGABRT 6 由 abort(3)发出的退出指令
#define SIGIOT 6 IOT 指令
#define SIGBUS 7 总线错误
#define SIGFPE 8 浮点运算错误
#define SIGKILL 9 杀死、终止进程
#define SIGUSR1 10 用户自定义信号 1
#define SIGSEGV 11 段违例(无效的内存段)
#define SIGUSR2 12 用户自定义信号 2
#define SIGPIPE 13 向非读管道写入数据
#define SIGALRM 14 闹钟
#define SIGTERM 15 软件终止
#define SIGSTKFLT 16 栈异常
#define SIGCHLD 17 子进程结束
#define SIGCONT 18 进程继续
#define SIGSTOP 19 停止进程的执行,只是暂停
#define SIGTSTP 20 停止进程的运行(Ctrl+Z 组合键)
#define SIGTTIN 21 后台进程需要从终端读取数据
#define SIGTTOU 22 后台进程需要向终端写数据
#define SIGURG 23 有"紧急"数据
#define SIGXCPU 24 超过 CPU 资源限制
#define SIGXFSZ 25 文件大小超额
#define SIGVTALRM 26 虚拟时钟信号
#define SIGPROF 27 时钟信号描述
#define SIGWINCH 28 窗口大小改变
#define SIGIO 29 可以进行输入/输出操作
#define SIGPOLL SIGIO
#define SIGPWR 30 断点重启
#define SIGSYS 31 非法的系统调用
#define SIGUNUSED 32 未使用信号

磁盘/网络数据可读可写也可以通过SIGIO信号通知到进程,也就是所说的“信号驱动式IO”。但更广泛的是通过事件通知。除了信号驱动和事件通知(非阻塞IO),进程默认是阻塞等待数据可读可写,这是通过软中断实现。软中断获得数据并把数据拷贝到进程空间后,会唤醒正在睡眠的进程,将进程从等待队列转到就绪队列。

事件通知

采用阻塞IO的进程会有先进入等待队列,数据就绪后重新进入就绪队列的逻辑。如果采用非阻塞IO,进程不会进入等待队列。

linux的字符设备/块设备/网络IO ,在进程中可以用文件描述符fd标识。设置文件描述符为非阻塞模式(O_NONBLOCK)从而采用非阻塞IO。

文件描述符可以与多个事件关联,这些事件表示文件描述符的状态变化,例如数据可读、可写或发生错误。linux 内部采用了事件通知的模型

  1. 消费者(进程)注册感兴趣的事件和事件回调函数,例如进程注册感兴趣的读写事件
  2. 软中断将数据拷贝到用户进程空间后,会匹配注册的事件,执行对应的事件处理函数。
    1. 如果是select/poll,事件处理函数就是把事件设置到select 列表对应的fd,等待进程轮询找到满足事件的fd
    2. 如果是epoll,事件处理函数就是从红黑树找到对应等待的文件描述符,将fd转移到就绪队列,唤醒阻塞在epoll_wait等待的进程。epoll_wait还会通过超时解除阻塞。

事件通知驱动和主动轮询驱动 对协调模块的设计,例如消息队列, 也具有很大参考意义

锁是协调多个任务对临界区的执行,锁保证,一个任务要么独占执行完临界区,要么不执行临界区,不允许临界区同时被多个任务执行。

单机锁主要有三种,自旋锁,互斥锁,读写锁。自旋锁是互斥锁一种特殊实现

  1. spinlock 主要是保护多cpu操作对象的安全,可以是cpu共享的硬件变量,为了防止死锁,加spinlock需要屏蔽当前cpu的中断。为了防止死锁,spinlock需要关中断和禁止抢占
  2. mutex,存放在内存的共享变量,维护多线程的安全
  3. 读写锁,使用count实现。读锁count-1,写锁count-很大的magic number。通过count值能知道目前持有读锁还是写锁。读写锁问题是读操作不需要等待,如果对象持续被读,写会被饿死。