Linux 进程与线程(一)基本概念与信号
基本概念
- 进程:资源管理的最小单位
- 线程:程序执行的最小单位
每个进程拥有自己的数据段、代码段和堆栈段。
线程通常叫做轻型的进程,包含独立的栈和CPU寄存器状态。线程是进程的一条执行路径,每个线程共享其所附属进程的所有资源。
线程和进程比起来很小,相对来说线程花费更少的CPU资源。
更形象一点:
- 进程是资源的管理单位
- 进程就像一个工厂,里面有自己的设备(CPU、内存、文件、网络连接等)
- 每个工厂(进程)都是独立的,工厂之间不能直接共享设备(但可以通过通信合作)
- 线程是执行的基本单位
- 线程就像工人,他们在工厂(进程)里面工作,负责具体的任务
- 一个工厂(进程)可以有多个工人(线程),他们一起工作,提高效率
- 线程共享进程资源
- 工厂里的工人(线程)共用工厂的资源,比如机器(内存、文件、网络等)
- 但工人们有自己的工作台(栈),不会互相干扰自己的操作步骤
- 进程之间是隔离的,线程之间是共享的
- 两个工厂(进程)不能随便使用对方的设备(资源隔离)
- 但同一个工厂里的工人(线程)可以直接使用工厂的设备(共享资源)
多线程适合任务紧密相关,共享数据的场景;多进程适合任务独立,互不干扰的场景。
ULT和KLT
- 用户级线程(User-Level Thread, ULT)
- 内核级线程(Kernel-Level Thread, KLT)
用户级线程是完全由用户态的线程库管理,操作系统内核并不知道这些线程的存在。
- 线程管理由用户程序自己负责(就像选手自己决定何时跑、休息)
- 切换线程时不需要内核介入,上下文切换更快
- 如果一个线程阻塞,整个进程都会挂起(一个选手倒下,整个队伍都停下)
- 适合轻量级任务,但不适合I/O密集型任务
内核级线程是由操作系统内核管理的线程,切换线程由内核调度器负责。
- 内核知道所有线程的存在,并负责调度(裁判决定选手何时跑)
- 一个线程阻塞不会影响整个进程(一个选手休息,其他人还能继续跑)
- 线程切换涉及内核态和用户态切换,开销较大
- 适合多核CPU调度、多线程并发任务,如 I/O 密集型任务
信号
简介
信号是事件发生时一个进程对另一个进程的通知机制,也可以被称作软件中断。大多数情况下进程无法预测信号到达的时间,因此信号提供了一种处理异步事件的方法。
信号可以由以下情况产生:
- 硬件异常:如除数为0、数组访问越界等异常,硬件会在检测到异常后通知Kernel,由Kernel通知进程
- 终端输入能够产生信号的特殊字符,如Ctrl+C和Ctrl+Z
kill()
:此方法有限制,接收信号的进程和发送信号的进程的所有者必须相同,或发送信号的进程的所有者为root- 发生软件事件,如定时器超时、CPU时间超限、子进程退出等等
和RTOS相似,信号本质上就是一个int类型的数字编号,“传递信号”实际上就是传递一个整形变量。
信号的处理方式
- 忽略信号:信号到达进程后,进程直接忽略该信号,除了SIGKILL和SIGSTOP
- 捕获信号:信号到达进程后,执行预先绑定后好的信号处理函数(回调函数)
- 执行系统默认操作:对于大多数信号,系统的默认处理方式即为终止该进程
常见信号与默认行为
信号编号 | 信号名称 | 含义 | 默认操作 |
---|---|---|---|
1 | SIGHUP | 终端挂起或控制进程终止 | 终止 |
2 | SIGINT | 键盘中断 (Ctrl+C) | 终止 |
3 | SIGQUIT | 键盘退出 (Ctrl+) | 核心转储 |
4 | SIGILL | 非法机器语言指令 | 核心转储 |
5 | SIGTRAP | 跟踪断点 | 核心转储 |
6 | SIGABRT | 异常终止 (abort函数) | 核心转储 |
7 | SIGBUS | 总线错误(某种内存访问错误) | 核心转储 |
8 | SIGFPE | 浮点异常 | 核心转储 |
9 | SIGKILL | 强制终止 | 终止(不可捕获) |
10 | SIGUSR1 | 用户自定义信号1 | 终止 |
11 | SIGSEGV | 段错误(无效内存引用) | 核心转储 |
12 | SIGUSR2 | 用户自定义信号2 | 终止 |
13 | SIGPIPE | 管道破裂(写无读端) | 终止 |
14 | SIGALRM | 定时器超时 | 终止 |
15 | SIGTERM | 终止请求 | 终止 |
17 | SIGCHLD | 子进程状态变化 | 忽略 |
18 | SIGCONT | 继续执行 | 忽略(可捕获) |
19 | SIGSTOP | 停止(不可忽略、不可捕获) | 停止 |
20 | SIGTSTP | 终端停止信号 (Ctrl+Z) | 停止 |
21 | SIGTTIN | 后台进程请求读取终端 | 停止 |
22 | SIGTTOU | 后台进程请求写终端 | 停止 |
💡 默认操作说明:
- 终止:终止进程。
- 核心转储:终止进程并生成 core 文件(用于调试)。
- 忽略:信号被忽略,进程不受影响。
- 停止:暂停进程,等待 SIGCONT 恢复。
进程处理信号
signal()
signal()
是Linux下设置信号处理方式最简单的接口,可设置信号的处理方式为捕获、忽略或默认操作:
1 |
|
形参:
signum
:指定需要设置的信号,可用信号名(宏)或信号的数字编号handler
:指向对应的信号处理函数,也可设置为SIG_IGN
(忽略)或SIG_DFL
(默认操作)
sigaction()
1 |
|
形参:
signum
:待设置信号,除了SIGKILL和SIGSTOPact
:指向一个struct sigaction结构,描述了信号的处理方式oldact
:信号之前的处理方式等信息通过该参数返回
sigaction结构体:
1 |
|
sa_handler
:指定的信号处理函数sa_sigaction
:也用于指定信号处理函数,这是替代的信号处理函数,提供了更多的参数,与sa_handler
互斥sa_mask
:定义了一组信号,当进程在执行由 sa_handler 所定义的信号处理函数之前,会先将这组信号添加到进程的信号掩码字段中,当进程执行完处理函数之后再恢复信号掩码,将这组信号从信号掩码字段中删除。当进程在执行信号处理函数期间,可能又收到了同样的信号或其它信号,从而打断当前信号处理函数的执行,类似中断嵌套;如果进程接收到了信号掩码中的这些信号,那么这个信号将会被阻塞暂时不能得到处理,直到这些信号从进程的信号掩码中移除。在信号处理函数调用时,进程会自动将当前处理的信号添加到信号掩码字段中,这样保证了在处理一个给定的信号时,如果此信号再次发生,那么它将会被阻塞。sa_restorer
:已过时sa_flags
:标志位,用于控制信号的处理过程SA_NOCLDSTOP
:子进程停止时(即当它们接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU中的一种时)或恢复(即它们接收到 SIGCONT)时不会收到 SIGCHLD 信号SA_NOCLDWAIT
:子进程终止时不会将其转变为僵尸进程SA_NODEFER
:不要阻塞从某个信号自身的信号处理函数中接收此信号。也就是说当进程此时正在执行某个信号的处理函数,默认情况下,进程会自动将该信号添加到进程的信号掩码字段中,从而在执行信号处理函数期间阻塞该信号,默认情况下,我们期望进程在处理一个信号时阻塞同种信号,否则引起一些竞态条件;如果设置SA_NODEFER 标志,则表示不对它进行阻塞。SA_RESETHAND
:信号的处理方式设置为系统默认操作SA_RESTART
:被信号中断的系统调用在信号处理完成之后将自动重新发起SA_SIGINFO
:如果设置了该标志,则表示使用 sa_sigaction 作为信号处理函数、而不是 sa_handler。
sigsuspend()
sigsuspend()
是pause()
的高级版本,当需要安全、精确地等待一个特定信号,而又不想错过它时,就该用 sigsuspend()
。
1 |
|
mask
指向一个临时的信号屏蔽集(阻塞集)。sigsuspend(&mask_set)
实际上就意味着只有“要等的那个信号”能打断挂起、触发处理器,屏蔽其他所有不关心的信号。
假设有两个进程,子进程执行完一个任务后,通过信号告诉父进程可以继续了。这时候你不能用 pause()
,因为你可能在设置好处理函数之前就错过了信号:
- 父进程提前屏蔽 SIGUSR1(防止它在处理器没准备好时到来)
- 安装信号处理器
- 子进程用 kill(getppid(), SIGUSR1) 发信号
- 父进程使用 sigsuspend() 临时取消屏蔽,挂起自己直到 SIGUSR1 到达
- 信号处理器运行,sigsuspend() 返回,父进程继续执行
这样做可以防止 race condition(竞争条件)—— 例如信号在 pause() 之前到来,结果永远挂起。
两种不同状态下信号的处理方式
- 程序启动
当一个应用程序刚启动,还未执行到signal()
处,或者程序中没有调用signal()
函数时,进程对所有信号的处理方式都设置为默认操作。这也就是为什么平时都可以使用Ctrl+C来终止一个进程。 - 进程创建
当一个进程调用 fork()创建子进程时,其子进程将会继承父进程的信号处理方式,因为子进程在开始时复制了父进程的内存映像,所以信号捕获函数的地址在子进程中是有意义的。
进程的内存布局
C语言程序的内存布局
1 |
|
进程的虚拟地址空间
Linux 为每个进程提供一个独立的虚拟地址空间,进程只能看到并访问自己的虚拟空间。实际的物理内存由内核通过页表映射维护:
- 进程隔离,互不干扰,提升安全性
- 可以实现内存保护、共享内存、内存映射等高级机制
- 支持更大的地址空间
以64位Linux系统为例:
1 |
|
x86下用户态虚拟地址空间上限为4GB,用户使用3GB,Kernel使用1GB。用户态程序操作的是虚拟地址,通过MMU(内存管理单元)由Kernel映射到真实的物理地址,用户态无法直接观测到物理地址。
创建子进程
fork()
1 |
|
fork()
调用后,将存在两个进程:
- 原进程(父进程)
- 新创建的子进程
子进程实际上是父进程的一个副本,子进程拷贝了父进程的数据段、堆、栈,同时继承了父进程fopen
的文件描述符,并且两个进程并不共享这些存储空间(除了代码段,它是只读的,父子进程共享代码段,内存中只存在一份代码段数据),因此fork()
之后每个进程均可修改各自的栈数据和堆中的变量。
fork()
本身在被调用一次之后,将会返回两次:一次在父进程中返回,一次在子进程中返回。父进程中fork()
返回新创建的子进程的PID,子进程中fork()
返回0。因此可以通过判断返回值的方式来判断是父进程还是子进程:
1 |
|
前面提到父进程和子进程会共享已经打开的文件标识符,那么这里就有两种可能的情况:
- 父进程先
fopen
,再fork()
两个进程指向同一个文件标识符,并共享文件偏移量,也就是说子进程改变了文章偏移量之后,也会影响的父进程,反之亦然。
- 父进程先
fork()
,再各自fopen()
两个进程获取到的文件标识符不同,各自拥有自己的文件编译量,进程之间不会同步文件偏移量的修改结果,因此写入的数据会发生竞争(覆盖或缺失)
vfork()
vfork()
与 fork()
函数主要有以下两个区别:
-
vfork()
与fork()
一样都创建了子进程,但vfork()
函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec
(或_exit
),于是也就不会引用该地址空间的数据。不过在子进程调用exec
或_exit
之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现会提高进程创建的效率;但如果子进程修改了父进程的数据(除了vfork
返回值的变量)、进行了函数调用、或者没有调用exec
或_exit
就返回将可能带来未知的结果。 -
另一个区别在于,
vfork()
保证子进程先运行,子进程调用exec
之后父进程才可能被调度运行。
实际应尽量避免vfork()
来创建子进程,防止出现未知结果。
fork()之后的竞争条件
fork()
之后父子进程都将被系统继续调度运行,此时无法确定父子两个进程谁先访问CPU,这导致两个进程谁先运行、谁后运行是不确定的(虽然大部分情况下都是父进程先执行)。这个时候可以通过某种进程间同步机制来实现,比如使用信号:
1 |
|
监视子进程
wait()
1 |
|
形参:
status
:存放子进程终止时的状态信息
系统调用wait()
将执行如下动作:
- 调用
wait()
函数,如果所有子进程都还在运行,则wait()
会一直阻塞等待,直到某个子进程终止 - 如果进程调用
wait()
,但是该进程没有子进程(该进程没有需要等待的子进程),那么wait()
将返回错误 - 如果进程调用
wait()
之前,其子进程已经有一个或多个终止了,那么调用wait()
将不会阻塞,而是会回收子进程的一些资源,即“收尸”。
形参status
不为NULL的情况下,可通过以下宏来检查其参数:
WIFEXITED(status)
:子进程正常终止,返回trueWEXITSTATUS(status)
:返回子进程退出状态