Skip to main content
Linux4,125 15 分钟阅读

linux系统(1)——进程管理和调度

作者

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

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 值越低,进程优先级越高。

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

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

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

Plain text
进程类别	默认 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 执行。

C
/*
*  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/进程上下文是否切换 等价于 是否出现硬中断。

C
/* 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中找到硬件中断处理函数执行。

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

Plain text
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软中断占用时间长,可能是网络包是大量的小包,也可能是磁盘/网络处理慢。

Plain text
软中断号	名称	用途
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. 进程在进入等待队列后刚被唤醒的时候

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

Plain text
#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值能知道目前持有读锁还是写锁。读写锁问题是读操作不需要等待,如果对象持续被读,写会被饿死。
L

作者

Larry

关注系统、存储与高性能计算,也记录生活与长期思考。

标签#linux