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
2
3
4
#include <signal.h>

typedef void (*sig_t)(int);
sig_t signal(int signum, sig_t handler);

形参:

  • signum:指定需要设置的信号,可用信号名(宏)或信号的数字编号
  • handler:指向对应的信号处理函数,也可设置为SIG_IGN(忽略)或SIG_DFL(默认操作)

sigaction()

1
2
3
#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

形参:

  • signum:待设置信号,除了SIGKILL和SIGSTOP
  • act:指向一个struct sigaction结构,描述了信号的处理方式
  • oldact:信号之前的处理方式等信息通过该参数返回

sigaction结构体:

1
2
3
4
5
6
7
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t*, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}
  • 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
2
3
#include <signal.h>

int sigsuspend(const sigset_t *mask);

mask指向一个临时的信号屏蔽集(阻塞集)。sigsuspend(&mask_set)实际上就意味着只有“要等的那个信号”能打断挂起、触发处理器,屏蔽其他所有不关心的信号。

假设有两个进程,子进程执行完一个任务后,通过信号告诉父进程可以继续了。这时候你不能用 pause(),因为你可能在设置好处理函数之前就错过了信号:

  1. 父进程提前屏蔽 SIGUSR1(防止它在处理器没准备好时到来)
  2. 安装信号处理器
  3. 子进程用 kill(getppid(), SIGUSR1) 发信号
  4. 父进程使用 sigsuspend() 临时取消屏蔽,挂起自己直到 SIGUSR1 到达
  5. 信号处理器运行,sigsuspend() 返回,父进程继续执行

这样做可以防止 race condition(竞争条件)—— 例如信号在 pause() 之前到来,结果永远挂起。

两种不同状态下信号的处理方式

  • 程序启动
    当一个应用程序刚启动,还未执行到signal()处,或者程序中没有调用signal()函数时,进程对所有信号的处理方式都设置为默认操作。这也就是为什么平时都可以使用Ctrl+C来终止一个进程。
  • 进程创建
    当一个进程调用 fork()创建子进程时,其子进程将会继承父进程的信号处理方式,因为子进程在开始时复制了父进程的内存映像,所以信号捕获函数的地址在子进程中是有意义的。

进程的内存布局

C语言程序的内存布局

1
2
3
4
5
6
7
8
9
10
11
12
虚拟地址向上递增
┌──────────────────────────┐
│ 栈(Stack) │ ← 局部变量、函数参数、返回地址
├──────────────────────────┤
│ 堆(Heap) │ ← malloc / calloc / realloc 动态分配
├──────────────────────────┤
│ BSS段(.bss) │ ← 未初始化的全局变量 & 静态变量(全为0)
├──────────────────────────┤
│ 数据段(.data) │ ← 初始化的全局变量 & 静态变量
├──────────────────────────┤
│ 代码段(.text) │ ← 可执行代码、只读常量(有时包含.rodata
└──────────────────────────┘

进程的虚拟地址空间

Linux 为每个进程提供一个独立的虚拟地址空间,进程只能看到并访问自己的虚拟空间。实际的物理内存由内核通过页表映射维护:

  • 进程隔离,互不干扰,提升安全性
  • 可以实现内存保护、共享内存、内存映射等高级机制
  • 支持更大的地址空间

以64位Linux系统为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌────────────────────────────┐
│ 用户栈 (stack) │ ← 地址高,向下增长
├────────────────────────────┤
│ mmap 区域(匿名映射/共享库) │
├────────────────────────────┤
│ 堆 (heap) │ ← 向上增长
├────────────────────────────┤
│ BSS段(.bss) │
├────────────────────────────┤
│ 数据段(.data) │
├────────────────────────────┤
│ 代码段(.text) │
├────────────────────────────┤
NULL保留区(不可访问) │ ← 低地址,保护空指针访问
└────────────────────────────┘

x86下用户态虚拟地址空间上限为4GB,用户使用3GB,Kernel使用1GB。用户态程序操作的是虚拟地址,通过MMU(内存管理单元)由Kernel映射到真实的物理地址,用户态无法直接观测到物理地址。

创建子进程

fork()

1
2
3
#include <unistd.h>

pid_t fork(void);

fork()调用后,将存在两个进程:

  • 原进程(父进程)
  • 新创建的子进程

子进程实际上是父进程的一个副本,子进程拷贝了父进程的数据段、堆、栈,同时继承了父进程fopen的文件描述符,并且两个进程并不共享这些存储空间(除了代码段,它是只读的,父子进程共享代码段,内存中只存在一份代码段数据),因此fork()之后每个进程均可修改各自的栈数据和堆中的变量。

fork() 本身在被调用一次之后,将会返回两次:一次在父进程中返回,一次在子进程中返回。父进程中fork()返回新创建的子进程的PID,子进程中fork()返回0。因此可以通过判断返回值的方式来判断是父进程还是子进程:

1
2
3
4
5
6
7
8
9
pid_t pid = fork();

if (pid > 0) {
// 父进程
} else if (pid == 0) {
// 子进程
} else {
// 错误处理
}

前面提到父进程和子进程会共享已经打开的文件标识符,那么这里就有两种可能的情况:

  • 父进程先fopen,再fork()

两个进程指向同一个文件标识符,并共享文件偏移量,也就是说子进程改变了文章偏移量之后,也会影响的父进程,反之亦然。

  • 父进程先fork(),再各自fopen()

两个进程获取到的文件标识符不同,各自拥有自己的文件编译量,进程之间不会同步文件偏移量的修改结果,因此写入的数据会发生竞争(覆盖或缺失)

vfork()

vfork()fork()函数主要有以下两个区别:

  • vfork()fork()一样都创建了子进程,但 vfork()函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或_exit),于是也就不会引用该地址空间的数据。不过在子进程调用 exec_exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现会提高进程创建的效率;但如果子进程修改了父进程的数据(除了 vfork 返回值的变量)、进行了函数调用、或者没有调用 exec_exit 就返回将可能带来未知的结果。

  • 另一个区别在于,vfork()保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行。

实际应尽量避免vfork()来创建子进程,防止出现未知结果。

fork()之后的竞争条件

fork()之后父子进程都将被系统继续调度运行,此时无法确定父子两个进程谁先访问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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>

static void sig_handler(int sig)
{
printf("接收到信号\n");
}

int main(void)
{
struct sigaction sig = {0};
sigset_t wait_mask; /* 信号集 */

sigemptyset(&wait_mask); /* 初始化信号集 */

sig.sa_handler = sig_handler;
sig.sa_flags = 0;
if (sigaction(SIGUSR1, &sig, NULL) == -1){
perror("sigaction error");
exit(-1);
}

switch (fork())
{
case -1:
perror("fork error");
exit(-1);

case 0:
printf("子进程开始执行\n");
printf("子进程打印信息\n");
printf("~~~~~~~~~~~~~~~\n");
sleep(2);
kill(getppid(), SIGUSR1); /* 向父进程发送SIGUSR1信号 */
_exit(0);

default:
/* 不屏蔽任何信号,等待信号到达 */
if (sigsuspend(&wait_mask) != -1){
exit(-1);
}
printf("父进程开始执行\n");
printf("父进程打印信息\n");
exit(0);
}
}

监视子进程

wait()

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

形参:

  • status:存放子进程终止时的状态信息

系统调用wait()将执行如下动作:

  • 调用wait()函数,如果所有子进程都还在运行,则wait()会一直阻塞等待,直到某个子进程终止
  • 如果进程调用wait(),但是该进程没有子进程(该进程没有需要等待的子进程),那么wait()将返回错误
  • 如果进程调用wait()之前,其子进程已经有一个或多个终止了,那么调用wait()将不会阻塞,而是会回收子进程的一些资源,即“收尸”。

形参status不为NULL的情况下,可通过以下宏来检查其参数:

  • WIFEXITED(status):子进程正常终止,返回true
  • WEXITSTATUS(status):返回子进程退出状态

Linux 进程与线程(一)基本概念与信号
http://akichen891.github.io/2025/03/31/Linux线程1/
作者
Aki
发布于
2025年3月31日
更新于
2025年4月21日
许可协议