4063 字
20 分钟
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
  • 捕获信号:信号到达进程后,执行预先绑定后好的信号处理函数(回调函数)
  • 执行系统默认操作:对于大多数信号,系统的默认处理方式即为终止该进程

常见信号与默认行为#

信号编号信号名称含义默认操作
1SIGHUP终端挂起或控制进程终止终止
2SIGINT键盘中断 (Ctrl+C)终止
3SIGQUIT键盘退出 (Ctrl+)核心转储
4SIGILL非法机器语言指令核心转储
5SIGTRAP跟踪断点核心转储
6SIGABRT异常终止 (abort函数)核心转储
7SIGBUS总线错误(某种内存访问错误)核心转储
8SIGFPE浮点异常核心转储
9SIGKILL强制终止终止(不可捕获)
10SIGUSR1用户自定义信号1终止
11SIGSEGV段错误(无效内存引用)核心转储
12SIGUSR2用户自定义信号2终止
13SIGPIPE管道破裂(写无读端)终止
14SIGALRM定时器超时终止
15SIGTERM终止请求终止
17SIGCHLD子进程状态变化忽略
18SIGCONT继续执行忽略(可捕获)
19SIGSTOP停止(不可忽略、不可捕获)停止
20SIGTSTP终端停止信号 (Ctrl+Z)停止
21SIGTTIN后台进程请求读取终端停止
22SIGTTOU后台进程请求写终端停止

💡 默认操作说明:

  • 终止:终止进程。
  • 核心转储:终止进程并生成 core 文件(用于调试)。
  • 忽略:信号被忽略,进程不受影响。
  • 停止:暂停进程,等待 SIGCONT 恢复。

进程处理信号#

signal()#

signal()是Linux下设置信号处理方式最简单的接口,可设置信号的处理方式为捕获、忽略或默认操作:

#include <signal.h>
typedef void (*sig_t)(int);
sig_t signal(int signum, sig_t handler);

形参:

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

sigaction()#

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

形参:

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

sigaction结构体:

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()

#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语言程序的内存布局#

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

进程的虚拟地址空间#

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

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

以64位Linux系统为例:

┌────────────────────────────┐
│ 用户栈 (stack) │ ← 地址高,向下增长
├────────────────────────────┤
│ mmap 区域(匿名映射/共享库) │
├────────────────────────────┤
│ 堆 (heap) │ ← 向上增长
├────────────────────────────┤
│ BSS段(.bss) │
├────────────────────────────┤
│ 数据段(.data) │
├────────────────────────────┤
│ 代码段(.text) │
├────────────────────────────┤
│ NULL保留区(不可访问) │ ← 低地址,保护空指针访问
└────────────────────────────┘

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

创建子进程#

fork()#

#include <unistd.h>
pid_t fork(void);

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

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

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

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

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,这导致两个进程谁先运行、谁后运行是不确定的(虽然大部分情况下都是父进程先执行)。这个时候可以通过某种进程间同步机制来实现,比如使用信号:

#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()#

#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 进程与线程(一)基本概念与信号
https://arkmostima.pages.dev/posts/linux线程1/
作者
Aki
发布于
2025-03-31
许可协议
CC BY-NC-SA 4.0