title: 面试-进程线程与IPC tags: [
嵌入式Linux,
面试,
信号,
进程,
进程间通信,
IPC,
多线程,
线程同步,
pthread,
IMX6ULL,
] created: 2026-09-18 updated: 2026-09-18
💡 关联知识:[[02-进程线程与IPC/01-信号机制]]、[[02-进程线程与IPC/02-进程管理]]、[[02-进程线程与IPC/03-进程间通信]]、[[02-进程线程与IPC/04-线程与线程同步]]
本篇把「信号、进程、进程间通信、线程与同步」四大主题的高频面试题集中整理。每题按「答案要点 → 详细解答(含代码与对比表)→ 2 条追问」组织,可直接用于面试前突击复习。
⚠️ 来源说明:本篇以《I.MX6U嵌入式Linux C应用编程指南》第八~十二章为事实来源进行面试化整理,其中少量对比、术语(如信号量的 P/V 操作、UNIX domain socket 等)属于通用操作系统知识扩展。
答案要点:信号是事件发生时对进程的通知机制,是软件层次对中断机制的模拟;异步、无法预测到达时间;本质是 int 型编号。
详细解答:信号与硬件中断相似——都能打断程序当前执行的正常流程,区别是它在软件层实现。因为产生信号的事件对进程而言随机出现、进程无法预知准确时间,所以信号提供了一种处理异步事件的方法。信号本质上是 int 类型的数字编号(类似中断号),内核为每个信号定义唯一编号,从 1 开始,程序中使用符号名(宏 SIGxxx)而非硬编码编号。
信号的产生来源:
| 来源 | 例子 |
|---|---|
| 硬件异常 | 除数为 0(SIGFPE)、非法指令(SIGILL)、无效内存引用(SIGSEGV) |
| 终端特殊字符 | Ctrl+C → SIGINT,Ctrl+Z → SIGTSTP,Ctrl+\ → SIGQUIT |
进程调用 kill() |
向另一进程或进程组发送任意信号 |
| kill 命令 | kill -9 xxx,内部即 kill() 系统调用 |
| 软件事件 | 定时器超时(SIGALRM)、子进程退出(SIGCHLD) |
追问:① 信号和条件变量里的"signal"是一回事吗?(不是,后者是线程同步通知,与 Linux 信号无关)② 进程能否给自己发信号?(可以,raise() 或 kill(getpid(), sig))
答案要点:都能设置信号处理方式;signal() 简单但移植性差、语义不完整;sigaction() 更复杂但更灵活、可移植,推荐使用。
详细解答:
| 对比项 | signal() | sigaction() |
|---|---|---|
| 复杂度 | 简单 | 较复杂 |
| 移植性 | 差(不同系统语义可能不同) | 好(POSIX 标准) |
| 获取旧处理方式 | 返回值返回 | 通过 oldact 参数 |
| 精细控制 | 无 | 可设置 sa_mask、sa_flags |
| 处理函数 | handler(int) |
sa_handler 或 sa_sigaction |
sigaction() 关键成员与标志:
sa_handler / sa_sigaction:互斥,前者用于标准信号,后者配合 SA_SIGINFO 可获取 siginfo_t(含 si_pid、si_value 等);sa_mask:执行处理函数期间额外阻塞的信号集,可避免竞态;sa_flags:SA_RESTART(被中断的系统调用自动重启)、SA_NODEFER(不自动阻塞自身)、SA_SIGINFO、SA_RESETHAND(执行完恢复默认)、SA_NOCLDWAIT(子进程退出不产生僵尸)等。
struct sigaction sig = {0};
sig.sa_handler = sig_handler;
sig.sa_flags = 0;
sigaction(SIGINT, &sig, NULL);
追问:① sigaction() 能否设置 SIGKILL/SIGSTOP?(不能,这两个信号不可捕获、阻塞或忽略)② 信号处理函数为什么越简单越好?(可能在任何时间点打断主程序,复杂逻辑会增大竞态风险)
答案要点:按可靠性分可靠信号与不可靠信号;按实时性分实时信号与非实时信号。编号 1~31 为不可靠/非实时(标准信号),34~64 为可靠/实时信号。
详细解答:
SIGRTMIN(34)。早期 UNIX 的不可靠性指"可能错误响应、可能丢失";Linux 改进后不必重新注册处理函数,主要问题是信号可能丢失(同一信号在处理期间再次到达只记一次)。SIGRTMIN ~ SIGRTMAX(34~64)。支持排队、不会丢失;配套发送函数 sigqueue()、绑定函数 sigaction()。早期 UNIX 只定义了 31 种信号,Linux 3.x 支持 64 种。前 31 种有预定义用途和默认动作,后 32 种为可靠信号。可用 kill -l 查看。
追问:① 标准信号和可靠信号在"多次发送"上的区别?(可靠信号排队每次传递,标准信号只传递一次)② SIGUSR1/SIGUSR2 属于哪类?有什么用途?(不可靠信号;供程序员自定义通知事件或进程同步)
答案要点:重点掌握 SIGINT、SIGKILL、SIGSTOP、SIGTERM、SIGCHLD、SIGALRM、SIGPIPE、SIGSEGV。
详细解答:
| 信号 | 编号 | 描述 | 默认操作 |
|---|---|---|---|
SIGINT |
2 | 终端中断符 Ctrl+C | 终止 |
SIGQUIT |
3 | 终端退出符 Ctrl+ | 终止 + core |
SIGKILL |
9 | 终极终止,不可阻塞/忽略/捕获 | 终止 |
SIGSEGV |
11 | 无效内存引用 | 终止 + core |
SIGPIPE |
13 | 向已关闭管道/socket 写 | 终止 |
SIGALRM |
14 | 定时器超时(alarm) | 终止 |
SIGTERM |
15 | 标准终止信号(kill 默认) | 终止 |
SIGCHLD |
17 | 子进程终止或停止 | 忽略 |
SIGCONT |
18 | 使停止的进程继续 | 继续 |
SIGSTOP |
19 | 必停,不可阻塞/忽略/捕获 | 停止 |
SIGTSTP |
20 | 终端停止符 Ctrl+Z | 停止 |
要点:SIGKILL/SIGSTOP 是仅有的两个不可忽略、不可捕获的信号,向内核和超级用户提供可靠的终止/停止手段。SIGTERM 是优雅终止的首选,程序可捕获它做资源清理;SIGKILL 应作为最后手段。SIGCHLD 默认忽略,父进程可捕获它异步回收子进程。
追问:① 为什么 kill -9 能终止一切进程?(SIGKILL 不可被阻塞、忽略或捕获)② SIGSEGV 产生的核心转储文件有什么用?(可用于事后调试、定位崩溃现场)
答案要点:kill() 向进程/进程组发信号;raise() 向自己发信号;alarm() 设定时器;pause() 挂起等待信号。
详细解答:
int kill(pid_t pid, int sig); 参数 pid 取值含义:
pid > 0:发送给指定进程;pid == 0:发送给当前进程组中的所有进程;pid == -1:发送给当前进程有权发送的每个进程(init 除外);pid < -1:发送给进程组 ID 为 -pid 的每个进程。sig == 0 时不发送信号但做错误检查,可用来判断进程是否存在(不存在返回 -1,errno=ESRCH)。发送需要权限:发送者的实际/有效用户 ID 必须等于接收者,root 例外。int raise(int sig); 等价于 kill(getpid(), sig),向自己发送信号。unsigned int alarm(unsigned int seconds); 设置一次性闹钟,超时后内核发送 SIGALRM。每个进程只能有一个 alarm 闹钟;若之前已设置且未超时,返回剩余秒数并被新值替代。不能循环触发,如需循环要在处理函数中重新调用。int pause(void); 使进程休眠,直到捕获到一个信号且处理函数返回,返回 -1、errno 置 EINTR。
alarm(3); /* 3 秒后发送 SIGALRM */
pause(); /* 挂起等待,被信号唤醒后返回 */
alarm()+pause() 可模拟 sleep(),但存在竞态:若信号在 alarm() 之后、pause() 之前到达,pause() 会永久阻塞。更安全的做法是用 sigsuspend()。
追问:① 为什么 alarm+pause 有竞态?如何解决?(用 sigsuspend() 把恢复掩码与挂起做成原子操作)② 子进程被暂停(SIGSTOP)会收到 SIGCHLD 吗?(会,若未设置 SA_NOCLDSTOP)
答案要点:信号掩码是一组被阻塞的信号;sigprocmask 修改掩码;sigpending 查询挂起信号;sigsuspend 原子地替换掩码并挂起。
详细解答:
signal()/sigaction() 注册处理函数时自动添加当前处理的信号(SA_NODEFER 除外);用 sa_mask 指定;用 sigprocmask() 显式添加/移除。int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); how 取值:
SIG_BLOCK:将 set 加入掩码(并集);SIG_UNBLOCK:从掩码移除;SIG_SETMASK:直接设置为 set。int sigpending(sigset_t *set); 获取处于等待(挂起)状态的信号集。int sigsuspend(const sigset_t *mask); 把进程信号掩码替换为 mask 并挂起进程,收到信号处理函数返回后恢复原掩码。它相当于原子地执行 sigprocmask(SIG_SETMASK,...,&old); pause(); sigprocmask(SIG_SETMASK,&old,NULL);,从而消除"恢复掩码到 pause 之间信号丢失"的竞态。
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL); /* 阻塞 SIGINT */
/* 受保护代码段 ... */
sigprocmask(SIG_UNBLOCK, &set, NULL); /* 解除阻塞 */
追问:① sigsuspend() 返回值是什么?(总是返回 -1,errno 通常为 EINTR)② 信号掩码在多线程下属于进程还是线程?(属于线程,用 pthread_sigmask() 设置各自掩码)
答案要点:实时信号支持排队、可携带伴随数据、传递顺序有保障、范围大;sigqueue 可发送实时信号并携带数据,kill 不能。
详细解答:
| 对比项 | 标准信号 | 实时信号 |
|---|---|---|
| 编号 | 1~31 | 34~64(SIGRTMIN~SIGRTMAX) |
| 是否排队 | 否,多次发送只记一次 | 是,多次发送多次传递 |
| 携带数据 | 否 | 是(union sigval,整型或指针) |
| 传递顺序 | 无保障 | 编号越小优先级越高;同类型按发送顺序 |
| 自定义数量 | 仅 SIGUSR1/SIGUSR2 |
31 个可用 |
使用实时信号的两个要求:
int sigqueue(pid_t pid, int sig, const union sigval value); 发送信号及伴随数据;接收进程用 sigaction() 绑定处理函数并设置 SA_SIGINFO,使用 sa_sigaction 而非 sa_handler,才能通过 siginfo_t->si_value 取到伴随数据。
union sigval v;
v.sival_int = 10;
sigqueue(pid, sig, v); /* 发送方 */
static void handler(int sig, siginfo_t *info, void *ctx) {
sigval_t v = info->si_value; /* 接收方取伴随数据 */
printf("data=%d\n", v.sival_int);
}
追问:① sigqueue 的 sig 设为 0 有什么用途?(检查进程是否存在,与 kill 相同)② 实时信号一定比标准信号"快"吗?(不是,"实时"指可靠排队,与速度无关)
答案要点:程序是磁盘上的静态可执行文件,进程是程序的一次运行过程、动态概念;进程有 6 种状态。
详细解答:程序是静态文件,进程是可执行程序的实例、是程序被加载到内存中运行后的动态过程,有生命周期(创建到终止)。每个进程有唯一进程号 PID(正数)。
Linux 进程的 6 种状态:
| 状态 | 说明 |
|---|---|
| 就绪态(Ready) | 满足被调度条件但未获 CPU,得到 CPU 即可运行 |
| 运行态 | 正被 CPU 调度执行 |
| 僵尸态 | 进程已结束,父进程尚未"收尸" |
| 可中断睡眠(浅睡) | 可被信号唤醒 |
| 不可中断睡眠(深睡) | 无法被信号唤醒,须等条件成立 |
| 暂停态 | 被信号暂停(如 SIGSTOP),可被 SIGCONT 恢复 |
浅睡和深睡统称等待态/阻塞态,处于等待态的进程不参与调度。新创建的进程处于就绪态。
追问:① 进程和线程谁被调度?(线程是调度最小单元,进程不是运行实体)② 阻塞态和暂停态有何区别?(阻塞在等事件,暂停是被强制停止,都可恢复)
答案要点:父进程返回子进程 PID,子进程返回 0,失败返回 -1;子进程复制数据段/堆/栈/文件描述符,共享代码段。
详细解答:pid_t fork(void); 调用一次返回两次:
errno。子进程是父进程的副本:拷贝父进程的数据段、堆、栈,并继承已打开的文件描述符(类似 dup(),父子对应 fd 指向同一个文件表,共享文件偏移量)。但父子不共享这些存储空间,各自修改各自的栈、堆互不影响。代码段(文本段)只读,父子共享同一份。子进程有独立的进程空间、PCB,被内核同等调度。
现代 Linux 用写时复制(copy-on-write) 技术:fork 时并不立即复制全部内存,只有某方写入时才复制相应页,以提高效率。
经典用法:./app arg 中 arg 由 shell 解析后通过加载器传给程序的引导代码,再传给 main(argc, argv)。
追问:① 为什么父、子进程一般只有一个用 exit()、另一个用 _exit()?(避免刷新同一份 stdio 缓冲区导致重复输出)② fork 后父子谁先运行?(不确定,需同步)
答案要点:都创建子进程、返回值相同;vfork() 不复制地址空间、子进程先运行、共享父进程内存,专为立即 exec 设计。
详细解答:
| 对比项 | fork() | vfork() |
|---|---|---|
| 地址空间 | 复制(写时复制) | 不复制,子进程共享父进程内存 |
| 执行顺序 | 不确定 | 保证子进程先运行,exec 后父进程才可能被调度 |
| 效率 | 有复制开销 | 效率更高 |
| 适用场景 | 通用 | 子进程立即调用 exec/_exit |
vfork() 的子进程在调用 exec 或 _exit 之前运行在父进程的空间中;若在此期间修改父进程数据(除返回值变量)、调用函数或未 exec/_exit 就返回,可能带来未知结果。
虽然 vfork() 效率更高,但容易引入难以察觉的 bug,且现代内核的 fork() 已用写时复制优化,所以实际开发中应优先使用 fork(),除非速度极其关键。
追问:① vfork 的子进程为什么不能调用 exit()?(会刷新并关闭父进程的 stdio 缓冲区)② 子进程立即 exec 的意义是什么?(用新程序替换自身,避免继承父进程不需要的资源)
答案要点:exit() 会执行终止处理函数、刷新 stdio 缓冲,再调用 _exit();_exit() 直接陷入内核终止。
详细解答:两者都终止进程,但 exit() 多做三件事:
atexit() 注册的进程终止处理函数;_exit() 系统调用。子进程应使用 _exit() 的原因:fork 后子进程复制了父进程的 stdio 缓冲区。若父子都用 exit(),两者都会刷新各自的缓冲区。例如 printf("Hello World!"); 后不带换行(行缓冲未刷新),fork 后子进程也拷贝了这段未刷新的数据,父子各自 exit() 会各刷新一次,导致字符串被打印两次。用 _exit() 的子进程不刷新缓冲区,即可避免重复输出。
其他避免方法:输出后加 \n(行缓冲立即刷新)、fork 前 fflush()、关闭 stdio 缓冲。注意 return 从 main 返回等价于 exit()。
追问:① atexit() 注册的函数在 _exit() 时会执行吗?(不会)② 终止状态的哪些位有效?(int 中仅低 8 位有效,0 表示成功)
答案要点:子进程先于父进程结束且父进程未回收→僵尸进程;父进程先于子进程结束→孤儿进程(被 init 收养)。
详细解答:
getppid() 返回 1(图形界面下可能被 upstart 等会话守护进程收养)。wait() 回收其资源,子进程处于"已终止但 PCB 未释放"的状态。用 ps 查看状态为 Z。僵尸进程无法被信号杀死(连 SIGKILL 也不行),因为它已经死了,只是在等父进程回收。危害:大量僵尸进程会填满内核进程表,阻碍新进程创建。
处理僵尸的方法:
wait()/waitpid() 回收(正常做法);waitpid(-1, NULL, WNOHANG) 非阻塞轮询;SIGCHLD 信号,在处理函数中循环 while (waitpid(-1, NULL, WNOHANG) > 0);;SIGCHLD 设为 SIG_IGN,让内核把子进程交给 init 处理(无需显式 wait);追问:① 为什么"收尸"必须由父进程做?(进程表项/退出状态需父进程读取后内核才释放)② SIGCHLD 处理函数里为什么用 while + WNOHANG?(多个子进程同时终止时信号可能合并,必须循环收完所有)
答案要点:wait() 阻塞等待任意子进程;waitpid() 可指定子进程、可非阻塞、可处理暂停/恢复。
详细解答:
pid_t wait(int *status); 的行为:无终止子进程时阻塞;无子进程时返回 -1、errno=ECHILD;已有终止子进程时立即回收。
pid_t waitpid(pid_t pid, int *status, int options); 的 pid:
>0:等待指定 PID;=0:等待同进程组的任意子进程;<-1:等待进程组 ID 为 |pid| 的任意子进程;=-1:等待任意子进程,等价于 wait()。options 常用标志:WNOHANG(非阻塞,无状态改变返回 0)、WUNTRACED(也返回暂停的子进程)、WCONTINUED(也返回被 SIGCONT 恢复的子进程)。
status 解析宏:
| 宏 | 含义 |
|---|---|
WIFEXITED(status) |
子进程正常终止返回真 |
WEXITSTATUS(status) |
取退出码 |
WIFSIGNALED(status) |
被信号终止返回真 |
WTERMSIG(status) |
取导致终止的信号编号 |
WCOREDUMP(status) |
是否产生 core 文件 |
追问:① waitpid(-1,&s,0) 与 wait(&s) 等价吗?(等价)② 为什么 WNOHANG 轮询会浪费 CPU?(忙等,应配合阻塞或信号机制)
答案要点:execve 是系统调用,其余 6 个是库函数;按参数形式(list/array)和是否查 PATH(p)、是否带 env(e)区分;fork+exec 实现"创建新进程运行另一个程序"。
详细解答:
| 函数 | 参数形式 | 查 PATH | 指定环境变量 |
|---|---|---|---|
execl |
可变参数列表 | 否 | 否 |
execv |
指针数组 | 否 | 否 |
execlp |
可变参数列表 | 是 | 否 |
execvp |
指针数组 | 是 | 否 |
execle |
可变参数列表 | 否 | 是 |
execvpe |
指针数组 | 是 | 是 |
execve(系统调用) |
数组 | 否 | 是 |
命名规律:l=list(逐个参数,NULL 结尾);v=vector(字符串数组);p=PATH(只给文件名即可);e=environment(自定义环境变量)。成功调用 exec 永不返回,返回即表示出错。exec 用新程序替换当前进程的代码段、数据段、堆栈,从新程序的 main() 开始执行,PID 不变。
fork+exec 的意义:子进程 fork 出一个副本后,立即 exec 载入新程序,既保留了"独立进程"的隔离性,又能执行任意可执行文件。这是 shell 执行命令、服务器处理请求的经典模式。相比直接写死在子进程分支,exec 更灵活、扩展性更好。
追问:① system() 的内部实现是什么?(fork + execl(shell)+ waitpid)② exec 后进程 PID 会变吗?(不变,只是程序映像被替换)
答案要点:运行在后台、独立于控制终端、长期运行的进程;编写步骤:fork+父退出、setsid、改工作目录、重设 umask、关文件描述符、重定向 0/1/2 到 /dev/null、忽略 SIGCHLD。
详细解答:守护进程(Daemon)的特点:长期运行(直到系统关机)、与控制终端脱离(不受用户登录注销影响)、自成进程组和会话(pid=gid=sid)。Linux 大多数服务器(httpd、crond 等)都是守护进程,名字常以 d 结尾。
编写步骤:
setsid():创建新会话,成为会话首领和进程组组长,摆脱原会话、原进程组、原控制终端;chdir("/"):避免占用可卸载的文件系统;umask(0):确保最大操作权限;/dev/null;signal(SIGCHLD, SIG_IGN):避免产生僵尸进程。
pid = fork();
if (pid > 0) exit(0); /* 父进程退出 */
setsid(); /* 创建新会话 */
chdir("/"); /* 工作目录 */
umask(0); /* 权限掩码 */
for (i = 0; i < sysconf(_SC_OPEN_MAX); i++) close(i);
open("/dev/null", O_RDWR); /* 0 */
dup(0); /* 1 */
dup(0); /* 2 */
signal(SIGCHLD, SIG_IGN);
守护进程常用文件锁实现单例运行:打开 /var/run/name.pid,用 flock(fd, LOCK_EX|LOCK_NB) 尝试加锁,失败说明已在运行;成功则写入 PID,程序退出自动解锁。
追问:① 为什么用 setsid 而不是只 fork 两次?(setsid 才能真正脱离控制终端、成为会话首领)② 关闭终端时普通进程为什么会退出?(收到 SIGHUP;忽略 SIGHUP 也能保命,但不够规范)
答案要点:两个进程之间的通信;机制分 UNIX IPC、System V IPC、POSIX IPC、Socket IPC 四类。
详细解答:每个进程有独立、隔离的地址空间,同一进程内不同模块用全局变量即可通信,但两个不同进程通信较难,需要内核提供 IPC 机制。大型程序(GUI、服务器)常设计成多进程,需要 IPC;中小型程序通常单进程(可多线程),较少用到。
Linux IPC 机制(从 UNIX 继承):
| 类别 | 机制 |
|---|---|
| UNIX IPC | 管道、FIFO、信号 |
| System V IPC | Signal V 信号量、消息队列、共享内存 |
| POSIX IPC | POSIX 信号量、消息队列、共享内存 |
| Socket IPC | 基于 socket 的进程间通信(可跨主机) |
AT&T 贝尔实验室形成了 System V IPC(局限在单机内);BSD 形成了基于 socket 的 IPC(可跨主机)。POSIX IPC 在 System V IPC 基础上改进,弥补其不足。
追问:① 信号算 IPC 吗?(算,是最原始的 IPC 形式之一)② 单机选哪种最快?(共享内存,配合信号量同步)
答案要点:普通管道单工、限亲缘进程;流管道半双工、限亲缘进程;FIFO 突破两种限制,非亲缘进程也可通信。
详细解答:
| 类型 | 方向 | 通信进程限制 |
|---|---|---|
普通管道 pipe |
单工(单向) | 只能在父子/兄弟进程间 |
流管道 s_pipe |
半双工(双向) | 只能在父子/兄弟进程间 |
| 有名管道 FIFO | 双向 | 无亲缘关系的进程也可通信 |
管道被抽象成一个文件(pipe 文件类型),是 UNIX 最古老的 IPC 方法(20 世纪 70 年代早期出现)。普通管道要实现双向传输必须用两个管道;FIFO 突破了"亲缘关系"和"单工"两种限制,任何进程只要知道 FIFO 路径就能通信。
追问:① 向已关闭的管道写会怎样?(产生 SIGPIPE 信号,默认终止进程)② 管道和 FIFO 的本质区别是什么?(FIFO 在文件系统中有名字,可被任意进程打开)
答案要点:消息的链表,存放在内核中,由消息队列标识符标识;克服了信号信息量少、管道无格式、缓冲区受限等缺陷。
详细解答:消息队列是 UNIX 下不同进程共享资源的一种机制,允许进程把格式化的数据流以消息形式发送给任意进程。有足够权限的进程可向队列添加消息,有读权限的进程可读走队列中的消息。消息队列包括 POSIX 消息队列和 System V 消息队列。
相比其它 IPC 的优势:
内核会为消息队列维护引用计数等状态,消息可长期存放在内核中,进程可异步收发。
追问:① 消息队列和管道的核心区别?(有消息边界和类型,非字节流)② System V 和 POSIX 消息队列怎么选?(新项目优先 POSIX,接口更规范)
答案要点:信号量是一个计数器,用于控制多个进程/线程对共享资源的访问,常作锁机制;信号(signal)是异步事件通知,两者完全不同。
详细解答:信号量是一个计数器,相当于内存中的标志。进程可根据它判定能否访问某些共享资源,也可修改它。除了共享资源访问控制外,还可用于进程同步。它常作为一种锁机制,防止某进程访问资源时其它进程也访问,因此主要作为进程间及同一进程内不同线程间的同步手段。Linux 提供一组信号量接口,声明在 <sys/sem.h> 中。
信号量有两个核心操作:P(等待/减一,资源不足则阻塞)和 V(释放/加一,唤醒等待者)。二值信号量(0/1)可实现互斥,计数信号量可控制并发数量。
与信号的区别:
| 对比项 | 信号量(semaphore) | 信号(signal) |
|---|---|---|
| 本质 | 计数器,用于同步/互斥 | 异步事件通知 |
| 用途 | 控制资源访问、进程同步 | 通知进程某事件发生 |
| 头文件 | <sys/sem.h> / <semaphore.h> |
<signal.h> |
追问:① 信号量初值设为 1 表示什么?(二值信号量,可实现互斥)② 信号量能携带数据吗?(不能,只表示资源计数)
答案要点:多个进程映射访问同一块内存,无需数据拷贝,所以最快;但共享内存本身不提供同步,必须用信号量等机制协调读写。
详细解答:共享内存就是映射一段能被其它进程访问的内存,由一个进程创建,其它进程都可访问,使多个进程访问同一块内存空间。它是最快的 IPC 方式,因为数据直接在内存中被多方读写,不需要在内核与用户空间之间来回拷贝(管道、消息队列都要经过内核缓冲区的拷贝)。
但共享内存只解决"怎么共享",不解决"何时能读、何时能写"。多个进程同时读写会产生竞态,因此通常与信号量(或互斥锁)配合,实现进程间的同步与通信。
进程 A ──┐ ┌── 进程 B
├─ 映射到同一物理内存 ─┤
│ (读写无需拷贝) │
└── 用信号量同步访问 ───┘
追问:① 共享内存的虚拟地址在两个进程中相同吗?(不一定,各自映射,地址可不同)② 共享内存会随进程退出自动销毁吗?(取决于实现,System V 共享内存需显式删除)
答案要点:Socket 是基于网络的 IPC,可跨越不同主机;其它 IPC 一般限于同一台机器。
详细解答:Socket 是一种 IPC 方法,基于网络,允许同一主机或通过网络连接的不同主机上的应用程序之间交换数据。典型客户端/服务器场景:
与管道、消息队列、共享内存相比,socket 的最大优势是可跨主机,是实现网络服务的基石;其它 IPC 机制通常局限于单机。同一主机内部通信也可用 UNIX domain socket,效率高且不经过网络协议栈。Socket 编程在提高篇网络编程章节详细介绍。
追问:① 本机进程间通信也用 socket 合适吗?(可用 UNIX domain socket,跨机时才必须用网络 socket)② socket 通信双方的标识是什么?(IP + 端口,服务器绑定知名端口)
答案要点:POSIX IPC 在 System V IPC 基础上改进,接口更规范、更可移植;选择 IPC 要看是否需要跨主机、数据量、同步需求与实时性。
详细解答:
| 对比项 | System V IPC | POSIX IPC |
|---|---|---|
| 出现时间 | 较早 | 较晚,改进自 System V |
| 接口风格 | 较繁琐(key、flag) | 更简洁、符合 POSIX 标准 |
| 可移植性 | 较差 | 更好(跨 UNIX 系统) |
| 消息通知 | 无 | 支持消息通知/优先级 |
| 涵盖 | 信号量、消息队列、共享内存 | 信号量、消息队列、共享内存 |
选择 IPC 的参考:
追问:① 为什么新项目推荐 POSIX IPC?(接口更标准、可移植性更好)② 共享内存和消息队列哪个快?(共享内存,无内核拷贝,但需自行同步)
答案要点:线程是调度最小单元、进程是资源容器;同进程线程共享地址空间,进程间独立隔离;线程切换开销小、通信容易。
详细解答:进程只是容器,真正运行的是进程中的线程。一个进程至少有一个主线程(main() 是主线程入口),可创建多个子线程并发运行。
共享与私有:
| 分类 | 内容 |
|---|---|
| 线程共享(属于进程) | 虚拟地址空间、代码段、数据段、堆、文件描述符、信号处理方式等 |
| 线程私有 | 线程栈、寄存器环境、线程本地存储(TLS) |
多进程 vs 多线程:
| 对比项 | 多进程 | 多线程 |
|---|---|---|
| 切换开销 | 大 | 小 |
| 通信 | 需 IPC,麻烦 | 共享内存,容易 |
| 创建速度 | 慢 | 快 |
| 多核利用 | 一般 | 更有优势 |
| 健壮性 | 隔离性好 | 一个线程崩溃可能拖垮进程 |
| 编程难度 | 较低 | 较高(线程安全、信号处理) |
内核调度的是线程而非进程,所以说线程是系统调度的最小单元。多进程模型常用于大型应用(网络服务器),中小型程序多用多线程。
追问:① 同一进程的两个线程的线程 ID 和进程 ID 相同吗?(进程 ID 相同,线程 ID 不同)② 线程能访问另一个线程的栈吗?(能,但极不安全,不应这样做)
答案要点:start 函数签名固定为 void *(*)(void *),只能传一个 void *;失败返回错误码而非设置 errno;需链接 -lpthread。
详细解答:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);
thread:成功时新线程 ID 存入;attr:线程属性,NULL 为默认;start_routine:新线程入口,签名固定;arg:唯一参数,需传多个时封装结构体,且应指向全局/堆变量保证生命周期;errno,用 strerror(ret) 翻译)。编译时必须链接 pthread 库,否则链接报 undefined reference to 'pthread_create':
gcc -o testApp testApp.c -lpthread
# 或
gcc -pthread -o testApp testApp.c
创建后无法确定是新线程还是主线程先运行;若主线程不 join 或休眠就退出,可能导致新线程还没来得及运行进程就结束了。
追问:① 为什么 pthread 函数失败不设置 errno?(设计上返回错误码更清晰,每个线程的 errno 只是兼容保留)② attr 传 NULL 和使用 PTHREAD_CREATE_DETACHED 有何区别?(前者默认可 join,后者创建即分离)
答案要点:join 等待并回收、detach 自动回收、cancel 请求退出。
详细解答:
| 函数 | 原型 | 作用 | 特点 |
|---|---|---|---|
pthread_join |
int pthread_join(pthread_t, void **retval); |
阻塞等待线程终止并回收,取退出码 | 不能非阻塞;未分离线程必须 join,否则成僵尸线程 |
pthread_detach |
int pthread_detach(pthread_t); |
设为分离态,终止后系统自动回收 | 不可逆;之后不能再 join |
pthread_cancel |
int pthread_cancel(pthread_t); |
发送取消请求 | 立即返回,不等待;默认如同 pthread_exit(PTHREAD_CANCELED) |
线程关系对等:任意线程都能 join 另一个线程(与进程不同,进程只有父进程能 wait)。PTHREAD_CANCELED 实际是 (void *)-1。多个线程同时 join 同一线程结果不确定。pthread_detach(pthread_self()) 可让线程自行分离。
追问:① 僵尸线程是什么?(线程已终止但无人 join,资源未回收)② 已分离的线程还能 join 吗?(不能,返回 EINVAL/Invalid argument)
答案要点:状态分 ENABLE/DISABLE;类型分 DEFERRED/ASYNCHRONOUS;取消点是一系列可响应取消的函数。
详细解答:
pthread_setcancelstate(int state, int *oldstate);
PTHREAD_CANCEL_ENABLE:可被取消(默认);PTHREAD_CANCEL_DISABLE:不可取消,请求被挂起,直到状态变为 ENABLE。pthread_setcanceltype(int type, int *oldtype);(仅 ENABLE 时有效)
PTHREAD_CANCEL_DEFERRED:延迟取消,到达取消点才响应(默认);PTHREAD_CANCEL_ASYNCHRONOUS:可能在任意时刻取消(应用极少)。取消点是一系列函数,执行到它们时才真正响应取消请求,如 sleep()、read()、write()、open()、close()、wait()、waitpid()、select()、pthread_join()、pthread_cond_wait()、sigwait() 等。空循环(无取消点)中的线程永远无法被取消;可用 pthread_testcancel() 手动产生取消点。
当线程 fork 子进程时,子进程继承取消状态和类型;调用 exec 时重置为默认(ENABLE + DEFERRED)。
追问:① 为什么默认是 DEFERRED 而不是 ASYNCHRONOUS?(避免在关键代码执行中途被取消导致资源泄漏/不一致)② pthread_testcancel() 在无线程取消请求时做什么?(通常什么都不做,仅产生一个取消点)
答案要点:多个线程并发访问共享资源形成竞态,导致数据不一致;需要同步机制保证同一时间只有一个线程访问。
详细解答:线程同步是为了保护共享资源的访问,解决数据一致性问题。当以下条件同时满足时才有必要同步:
如果变量只读,或多线程访问的是各自私有的局部变量,则无一致性问题。竞态的本质是多个线程并发访问共享资源,出现读写交错:例如线程 A 读变量、计算、写回,写操作非原子,线程 B 在中间读到旧值。
经典验证:两个线程各把全局变量递增 1000 万次,期望 2000 万,实际往往偏小。给读写过程加互斥锁后结果稳定正确。
pthread_mutex_lock(&mutex);
l_count = g_count;
l_count++;
g_count = l_count;
pthread_mutex_unlock(&mutex);
追问:① 只读的共享变量需要加锁吗?(一般不需要,除非有写者)② g_count++ 为什么不是原子的?(编译为读—改—写多条指令,可能被打断)
答案要点:两种初始化方式(宏、pthread_mutex_init);加锁/解锁配对;重复加锁或循环等待会死锁。
详细解答:
初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; /* 定义时初始化 */
/* 或 */
pthread_mutex_init(&mutex, NULL); /* 动态/先定义后初始化 */
操作:
| 函数 | 行为 |
|---|---|
pthread_mutex_lock |
未锁则上锁;已锁则阻塞 |
pthread_mutex_trylock |
未锁则上锁;已锁返回 EBUSY,不阻塞 |
pthread_mutex_unlock |
解锁(不能解锁未锁定或他人锁定的锁) |
pthread_mutex_destroy |
销毁(不能销毁未解锁的锁) |
死锁的典型场景:
两个线程以不同顺序请求两把锁:
/* 线程 A */ /* 线程 B */
lock(mutex1); lock(mutex2);
lock(mutex2); lock(mutex1); /* 循环等待 */
避免方法:① 所有线程按相同顺序加锁(定义锁的层级);② 用 trylock 尝试加锁,失败则释放已持有的所有锁后重试。互斥锁类型中 PTHREAD_MUTEX_ERRORCHECK 会在重复加锁时返回错误,可作调试工具;PTHREAD_MUTEX_RECURSIVE 允许同一线程多次加锁。
追问:① PTHREAD_MUTEX_RECURSIVE 加锁两次需解锁几次?(两次,加锁与解锁次数必须相等)② 为什么要求所有线程遵守相同的加锁规则?(否则未加锁的访问仍会破坏数据一致性)
答案要点:条件检测需在互斥锁保护下进行;wait 返回后条件可能已变或有虚假唤醒,必须用 while 重新检查。
详细解答:条件变量用于自动阻塞线程直到条件满足,它不保存状态。之所以要配合互斥锁,是因为条件本身由共享变量表示、对条件的检测通常需要访问共享资源,必须用互斥锁保护,否则会引发线程不安全。
pthread_cond_wait(&cond, &mutex) 的工作流程:调用前线程必须持锁;函数原子地把线程加入等待队列并解锁;被唤醒返回时重新加锁。此时因为释放过锁,其它线程可能已经修改了共享变量,所以条件不一定还成立。
必须用 while 而非 if 的两个理由:
可能发生虚假通知。
pthread_mutex_lock(&mutex);
while (0 >= g_avail) /* 必须用 while 重新检查 */
pthread_cond_wait(&cond, &mutex);
/* 处理 */
pthread_mutex_unlock(&mutex);
通知函数:pthread_cond_signal() 至少唤醒一个线程,更高效;pthread_cond_broadcast() 唤醒所有线程,总能产生正确结果。若发送信号时无线程等待,信号会丢失(条件变量不保存状态)。
追问:① signal 和 broadcast 怎么选?(只有一个等待者用 signal,多消费者/状态广播用 broadcast)② 条件变量能单独保存"已通知"状态吗?(不能,需借助共享变量)
答案要点:自旋锁忙等不休眠、临界区必须极短、可用于中断上下文;互斥锁阻塞休眠、开销大;读写锁读-读并发、读-写/写-写互斥,适合读多写少。
详细解答:
| 对比项 | 互斥锁 | 自旋锁 |
|---|---|---|
| 获取失败 | 阻塞休眠,唤醒后竞争 | 原地自旋,忙等占 CPU |
| 底层实现 | 基于自旋锁实现(更上层) | 更底层 |
| 开销 | 休眠/唤醒开销大 | 忙等开销,切换开销小 |
| 临界区 | 可较长 | 必须极短 |
| 中断上下文 | 不可用 | 可用(内核中自旋锁会自动禁止抢占) |
| 重复加锁 | 不一定死锁(视类型) | 必然死锁 |
读写锁有 3 种状态(读加锁、写加锁、未加锁),两条规则:
读-读可并发,所以读写锁比互斥锁并行性更高,适合读的次数远大于写的次数的场景。读写锁也称共享互斥锁。
选型建议:一般临界区用互斥锁;等待条件成立用条件变量;临界区极短且不希望休眠(或中断上下文)用自旋锁;读多写少用读写锁。实际开发中用得最多的是互斥锁 + 条件变量。
追问:① 自旋锁在单核上一定有效吗?(单核上自旋可能一直浪费 CPU,需谨慎)② 读写锁会导致写者饥饿吗?(可能,读者源源不断时写者可能长期拿不到锁)
内容来源:《I.MX6U嵌入式Linux C应用编程指南》第八~十二章