- C++ Linux 编程详解,含系统编程、工具链、内存与内核,结合代码/命令与场景
- 对应面试问答见 03-Resources/interview/C++ Linux 编程
- 完整系列:C++ 基础-初级、C++ 基础-中级、C++ 基础-高级、C++ STL、03-Resources/cpp/C++ 并发编程、03-Resources/cpp/C++ 网络编程、03-Resources/cpp/C++ 数据结构、03-Resources/cpp/C++ 设计模式、03-Resources/cpp/C++ 基本算法、03-Resources/cpp/C++ Linux 编程
- 当前覆盖:进程/线程、文件IO、信号、IPC(共享内存/信号量)、文件系统(inode/软硬链接)、虚拟内存/mmap、MMU/TLB、CPU亲和性/NUMA、上下文切换、守护进程、工具链、编译链接、内核基础
进程和线程的区别?Linux 下如何创建?
进程是资源分配的基本单位,拥有独立的地址空间、文件描述符表、信号处理等;线程是 CPU 调度的基本单位,共享进程的地址空间、打开的文件,但拥有独立的栈、寄存器和程序计数器。
| 维度 | 进程 | 线程 |
|---|---|---|
| 地址空间 | 独立 | 共享 |
| 创建/切换开销 | 大(fork 复制页表等) | 小(只新建栈与 TCB) |
| 通信方式 | 需 IPC(管道/共享内存/信号) | 直接读写共享内存 |
| 崩溃影响 | 一个进程崩溃不影响其它 | 一个线程崩溃可能带崩整个进程 |
Linux 下创建进程用 fork()(或 vfork/posix_spawn),创建线程用 pthread_create;二者底层都走 clone() 系统调用,只是传入的标志不同。
#include <unistd.h>
#include <sys/wait.h>
#include <pthread.h>
#include <cstdio>
void* worker(void*) {
printf("子线程 pid=%d\n", getpid());
return nullptr;
}
int main() {
// 1. 创建进程
pid_t pid = fork();
if (pid == 0) {
printf("子进程 pid=%d\n", getpid());
} else {
waitpid(pid, nullptr, 0); // 父进程等待子进程
}
// 2. 创建线程
pthread_t t;
pthread_create(&t, nullptr, worker, nullptr);
pthread_join(t, nullptr); // 等待线程结束
return 0;
}
能点出「进程是资源分配单位、线程是调度单位」,并补充 Linux 视角:内核不区分进程/线程,统一用 task_struct 表示,clone() 通过 CLONE_VM/CLONE_FILES 等标志决定共享多少资源。
高并发 Web 服务器(如 Nginx 的 worker 进程 + 每进程内事件循环)用进程隔离保证稳定性;而高性能计算、线程池则用线程降低切换开销。C++ 里通常用 std::thread 封装 pthread。
fork 的原理?什么是写时复制?
fork() 会复制调用进程,创建一个几乎完全相同的子进程:子进程获得父进程地址空间、文件描述符表、环境变量的副本,只有返回值不同(父进程得到子进程 pid,子进程得到 0)。
写时复制(Copy-On-Write, COW):fork 后父子进程的物理内存页并不会立刻拷贝,而是共享同一份物理页,并把页表项标记为只读;当任意一方尝试写某页时,触发缺页异常,内核才为该页分配新物理内存并拷贝。这样 fork 的开销被大幅降低,也避免了大量无用拷贝。
#include <unistd.h>
#include <sys/wait.h>
#include <cstdio>
#include <cstring>
char global_buf[4096];
int main() {
strcpy(global_buf, "hello");
pid_t pid = fork();
if (pid == 0) {
// 子进程写这一页 → 触发 COW,真正复制该页
strcpy(global_buf, "child");
printf("child: %s\n", global_buf);
} else {
wait(nullptr);
printf("parent: %s\n", global_buf); // 父进程不受影响
}
return 0;
}
提一句 vfork:子进程直接共享父进程地址空间(不 COW),且父进程阻塞直到子进程 exec 或退出,专为「fork 后立刻 exec」优化,历史遗留,现代系统已少用。
fork 后文件描述符是复制的(引用计数 +1),父子共享同一文件偏移量;而 pthread 线程间共享 fd 是同一份。多线程程序里 fork 只复制「调用 fork 的那个线程」,可能造成死锁(锁被其它线程持有),此时应使用 fork 后在子进程只调用 async-signal-safe 函数。
僵尸进程和孤儿进程?如何避免?
- 孤儿进程:父进程先于子进程退出,子进程被
init(现代 Linux 是 systemd 或 PID 1)收养,由其负责回收。孤儿进程无害,会正常被 init 清理。 - 僵尸进程:子进程退出后,内核仍保留其
task_struct(含退出状态),等待父进程调用wait()/waitpid()回收。若父进程一直不回收,进程状态显示为Z(defunct),长期堆积会耗尽 PID。
#include <unistd.h>
#include <sys/wait.h>
#include <cstdio>
int main() {
pid_t pid = fork();
if (pid == 0) {
_exit(0); // 子进程立即退出 → 变僵尸
}
sleep(5); // 这 5 秒内子进程是僵尸状态
waitpid(pid, nullptr, 0); // 回收,僵尸消失
return 0;
}检查僵尸进程:
ps aux | grep Z # STAT 列为 Z 即僵尸
top # 顶部显示 "zombie" 数量避免方法:
// 方法一:显式 wait / waitpid
waitpid(pid, nullptr, 0);
// 方法二:忽略 SIGCHLD,内核自动回收
signal(SIGCHLD, SIG_IGN);
// 方法三:SIGCHLD 处理器里调用 wait
void handler(int) { while (waitpid(-1, nullptr, WNOHANG) > 0); }
// 方法四(经典):二次 fork,让孙进程被 init 收养
if (fork() == 0) {
if (fork() == 0) { /* 真正干活,父进程(SIGCHLD已处理)变孤儿 */ _exit(0); }
_exit(0);
}SIG_IGN 忽略 SIGCHLD 只在 Linux 上会「自动回收」;POSIX 标准并不保证,可移植代码应显式 wait。僵尸进程「杀不掉」,因为它已死,只能靠父进程 wait 或杀掉父进程。
服务端守护进程(daemon)常见做法:主进程 fork 后退出,孙进程成为孤儿交给 init,从而脱离终端、避免僵尸;配合 setsid() 建立新会话。
Linux 文件 IO(open/read/write)?阻塞 vs 非阻塞?
Linux 一切皆文件,系统调用以文件描述符(fd)为核心。open 返回 fd,read/write 通过 fd 读写,close 释放。
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
int main() {
// O_RDWR 读写, O_CREAT 不存在则创建, 0666 权限
int fd = open("/tmp/test.txt", O_RDWR | O_CREAT, 0644);
if (fd < 0) { perror("open"); return 1; }
const char* msg = "hello linux\n";
write(fd, msg, strlen(msg));
lseek(fd, 0, SEEK_SET); // 移回文件头
char buf[64] = {0};
ssize_t n = read(fd, buf, sizeof(buf));
printf("read %zd bytes: %s", n, buf);
close(fd);
return 0;
}阻塞 vs 非阻塞:默认 fd 是阻塞的——read 在无数据可读时会一直等(如管道/套接字);设置 O_NONBLOCK 后,无数据时 read 立即返回 -1 且 errno == EAGAIN/EWOULDBLOCK。
int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
char c;
ssize_t n = read(fd, &c, 1);
if (n < 0 && errno == EAGAIN) {
printf("暂无输入,先干别的\n");
}
// 运行中动态切换阻塞态
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
阻塞 IO 会让线程「卡住」浪费资源;非阻塞 IO 需配合轮询或多路复用(select/poll/epoll)才有意义。epoll 是 Linux 高性能网络编程的核心,详见 03-Resources/cpp/C++ 网络编程。
终端交互用阻塞读即可;网络服务器必须用非阻塞 + epoll 避免一个慢连接拖垮整个进程;磁盘文件一般仍是阻塞读,非阻塞对普通文件通常不生效(数据总是「就绪」)。
标准 IO 和系统 IO 的区别?
- 系统 IO:
open/read/write/close(unistd.h/fcntl.h),直接陷入内核,无用户态缓冲,每次调用都有系统调用开销。 - 标准 IO:
fopen/fread/fwrite/fclose(stdio.h),在用户态维护一个缓冲区,攒满或fflush才真正调用系统 IO,从而减少系统调用次数,并自动做换行符等处理。
#include <cstdio>
#include <unistd.h>
#include <fcntl.h>
int main() {
// 系统 IO:每个 write 都是一次系统调用
int fd = open("/tmp/a.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
write(fd, "x", 1); // 1 次 syscall
write(fd, "y", 1); // 又 1 次 syscall
close(fd);
// 标准 IO:先写进 FILE* 的用户态缓冲区
FILE* fp = fopen("/tmp/b.txt", "w");
fputc('x', fp); // 只进缓冲区,不落盘
fputc('y', fp);
fflush(fp); // 才触发一次 write
fclose(fp);
return 0;
}缓冲模式(setvbuf):_IONBF 无缓冲、_IOLBF 行缓冲(终端默认,遇 \n 刷)、_IOFBF 全缓冲(文件默认)。
系统 IO 和标准 IO 不要混用同一个 fd:标准 IO 的缓冲会让系统 IO 读到「过期」数据,顺序错乱。fork 后标准 IO 缓冲区可能被双重写入(父子各自 flush 同一份缓冲数据)。
追求极致性能/低延迟(如数据库、网络层)直接走系统 IO 或自管理缓冲;日常读写文件、日志用标准 IO 更方便。strace 能看到标准 IO 一次写多字节、系统 IO 逐字节调用的差异。
虚拟内存的原理?分页机制?
虚拟内存让每个进程拥有独立、连续的虚拟地址空间,由 MMU(内存管理单元)把虚拟地址翻译成物理地址。好处:进程隔离、按需加载、共享库映射、内存超售、简化链接(各程序固定基址)。
分页机制:地址空间切成固定大小页(通常 4KB),物理内存切成等大页框(frame)。页表记录「虚拟页 → 物理页框」的映射,页表项(PTE)含物理页号、存在位、权限位、脏位等。
虚拟地址 = 页号(P) | 页内偏移(D)
│ 查页表
▼
物理地址 = 物理页框号 | 页内偏移(D)多级页表(x86-64 用 4 级:PGD→P4D→PUD→PMD→PTE)解决单级页表过大问题;TLB(Translation Lookaside Buffer)缓存最近翻译结果,加速查表。
#include <unistd.h>
#include <cstdio>
int main() {
printf("页大小: %ld 字节\n", sysconf(_SC_PAGESIZE)); // 通常 4096
return 0;
}虚拟内存三大能力:隔离、共享、换页。分页 vs 分段:分页固定大小、无外部碎片但可能内部碎片;分段按逻辑单元(代码/数据)划分,现代 x86 主要用分页,段只做权限控制。TLB miss 是性能热点,故要利用局部性。
动态库 .so 被所有进程映射到各自虚拟地址空间的同一段(物理页共享),节省内存;mmap、共享内存、swap 都建立在分页之上。
mmap 的原理和适用场景?
mmap 把文件(或匿名内存)直接映射到进程虚拟地址空间,访问映射区就像访问内存,读写由内核的页缓存(page cache)承担。相对 read/write 少了一次「内核缓冲区 → 用户缓冲区」的拷贝(零拷贝读)。
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <sys/stat.h>
int main() {
int fd = open("/tmp/data.bin", O_RDWR | O_CREAT, 0644);
ftruncate(fd, 4096); // 保证文件有 4096 字节
// MAP_SHARED: 修改会写回文件并跨进程共享;MAP_PRIVATE: COW
char* p = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
strcpy(p, "mapped data"); // 直接写内存 = 写文件
msync(p, 4096, MS_SYNC); // 刷回磁盘
munmap(p, 4096);
close(fd);
return 0;
}适用场景:
- 大文件随机访问:只映射需访问的页,按需换入,避免
lseek+ 多次 read。 - 进程间共享内存(IPC):
MAP_SHARED | MAP_ANONYMOUS,父子进程或无关进程共享。 - malloc 大块分配:glibc 大内存用匿名 mmap。
- 零拷贝 IO:
sendfile/mmap减少内核到用户的拷贝。
mmap 后对映射区的访问若发生缺页,会读磁盘产生 major fault;文件修改通过「脏页」延迟写回,msync 强制同步。MAP_SHARED 跨进程可见,MAP_PRIVATE 用 COW(改内存不影响文件)。
映射区访问越界(访问文件大小之外)会触发 SIGBUS;mmap 失败返回 MAP_FAILED(不是 NULL)。文件被截断后访问映射区同样可能 SIGBUS。
缺页中断的过程?
CPU 访问某个虚拟地址时,MMU 查 TLB/页表发现该页不在物理内存(PTE 存在位为 0),触发缺页异常(Page Fault),流程:
- MMU 产生 page fault 异常,陷入内核,保存现场。
- 内核查
do_page_fault:判断地址是否合法(属于该进程的合法区间),非法则发SIGSEGV(段错误)。 - 查找该虚拟页对应的后备存储:文件映射页 / 匿名页 / swap 分区。
- minor fault(次缺页):页在物理内存(如被其它进程共享),只需建立映射、更新 PTE。
- major fault(主缺页):页不在内存,需从磁盘(文件或 swap)读入,分配页框、拷贝数据。
- 更新页表、刷新 TLB,返回用户态,重新执行那条触发异常的指令。
#include <cstdio>
#include <unistd.h>
#include <sys/resource.h>
int main() {
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
// ru_minflt: 次缺页次数; ru_majflt: 主缺页次数
printf("minor faults=%ld, major faults=%ld\n",
ru.ru_minflt, ru.ru_majflt);
return 0;
}用 perf 观测缺页:
perf stat -e page-faults,minor-faults,major-faults ./your_program
缺页是「可恢复」的异常,与 SIGSEGV(非法访问、不可恢复)区分开。缺页正是 COW、按需加载、swap 的底层机制:fork 后首次写触发 COW 缺页,mmap 首次访问触发读盘缺页。
优化大对象分配:首次 memset 一大块内存会引发大量缺页,可考虑 mmap 大页(Huge Page,2MB/1GB)减少页表项与缺页次数,提升数据库、JVM 性能。
malloc 的底层实现(brk 和 mmap)?
glibc 的 malloc(ptmalloc 实现)不是每次都向内核要内存,而是维护一块「堆」。两种向内核申请内存的方式:
- brk/sbrk:调整进程数据段末尾的 program break 指针,得到的是连续的堆内存,适合小块分配(默认 < 128KB,
M_MMAP_THRESHOLD)。 - mmap:直接映射一块独立内存,适合大块分配(≥ 阈值),
free后立即munmap归还内核。
#include <cstdlib>
#include <cstdio>
int main() {
// 小块:走 brk,堆顶向上增长
char* small = (char*)malloc(1024);
// 大块:走 mmap,独立映射
char* big = (char*)malloc(1024 * 1024); // 1MB,超过阈值
printf("small=%p big=%p\n", small, big);
free(small);
free(big);
return 0;
}释放后的内存进入空闲链表(bins)复用:tcache、fastbins(小且不合并)、small/large bins、unsorted bin。分配时先查空闲链表,命中即返回,避免频繁系统调用。
阈值 mmap_threshold 可调(mallopt)。小内存释放后不一定立刻归还内核(留在堆里复用),大块 mmap 释放才真正 munmap。这就是「程序 free 后 RSS 不下降」的原因。
频繁小块分配(如链表节点)用 brk 高效;一次性大缓存/大数组用 mmap,释放即归还。性能敏感场景可用 malloc 替换为 jemalloc/tcmalloc(多线程 arena、减少锁竞争)。
Linux 信号机制?常见信号有哪些?
信号是内核或进程间的一种异步通知机制,用于通知进程「发生了某事件」。生命周期:产生 → 内核递送 → 进程处理。处理方式有默认动作(终止/忽略/停止)、忽略、自定义 handler(用 signal 或 sigaction)。
常见信号:
| 信号 | 值 | 含义 | 能否捕获 |
|---|---|---|---|
| SIGINT | 2 | Ctrl+C 中断 | 可 |
| SIGTERM | 15 | 终止(优雅退出) | 可 |
| SIGKILL | 9 | 强杀 | 不可捕获/忽略 |
| SIGSEGV | 11 | 段错误(非法内存访问) | 可(不推荐) |
| SIGPIPE | 13 | 向关闭的管道写 | 可 |
| SIGCHLD | 17 | 子进程状态改变 | 可 |
| SIGSTOP/SIGCONT | 19/18 | 停止/继续 | 不可捕获 |
#include <csignal>
#include <unistd.h>
#include <cstdio>
void on_sigint(int sig) {
printf("收到 SIGINT(%d),优雅退出\n", sig);
_exit(0);
}
int main() {
signal(SIGINT, on_sigint); // 注册 handler
signal(SIGPIPE, SIG_IGN); // 忽略
while (1) pause(); // 挂起等待信号
return 0;
}
推荐用 sigaction(可拿到发送者信息、避免 signal 的可移植性问题、能指定 SA_RESTART)。handler 内只能调用 async-signal-safe 函数(write、_exit 等),printf/malloc 都不安全。
标准信号不排队:同一种信号在 pending 期间多次到来只算一次;实时信号(SIGRTMIN+)才排队。写网络程序要处理 SIGPIPE,否则向已关闭 socket 写会直接杀死进程。
服务优雅停机:捕获 SIGTERM 做资源清理再退出;kill -9 是最后手段。SIGCHLD 用于回收子进程避免僵尸。
gdb 调试的常用命令?
gdb 是 Linux 下 C/C++ 调试器。编译时加 -g 保留符号信息。
gcc -g -O0 -o app main.c # -g 调试信息, -O0 关优化便于调试
gdb ./app # 启动
gdb --args ./app arg1 # 带参数
gdb -p 12345 # 附加到运行中的进程核心命令:
break main # 在函数入口断点
break file.cpp:42 # 在文件第 42 行断点
break func if x > 10 # 条件断点
run # 运行到断点
next (n) # 单步(不进入函数)
step (s) # 单步(进入函数)
finish # 执行完当前函数返回
continue (c) # 继续到下一断点
print x # 打印变量
print/x addr # 以十六进制打印
display x # 每步自动显示
backtrace (bt) # 查看调用栈
frame 1 # 切换栈帧
info locals # 当前帧局部变量
info threads # 查看线程
thread 2 # 切换线程
watch x # 监视变量被修改
list # 显示源码
core-file core # 分析 core dump# 崩溃后分析 core dump
ulimit -c unlimited
./app
gdb ./app core
bt # 直接定位崩溃栈
说清楚 next 与 step 的区别、bt 定位崩溃栈、条件断点/监视点。配合 -O0 调试、-g3 更全信息。TUI 模式 gdb -tui 或 layout split 显示源码。
线上崩溃先 bt 看栈;多线程死锁用 thread apply all bt 看每个线程卡在哪;变量异常用 watch。复杂界面可换 gdb 前端如 cgdb/gdb-dashboard,或 IDE 内置调试器(都基于 gdb 或 lldb)。
perf 如何分析性能瓶颈?
perf 是 Linux 内核自带的性能分析工具,基于 perf_event 子系统,可统计计数器、采样调用栈、生成火焰图。完整用法速查见 linux perf工具。
# 1. 统计整体计数(指令数、cache miss、上下文切换等)
perf stat ./app
# 2. 采样热点(哪个函数耗时最多)
perf record -g ./app # -g 记录调用栈
perf report # 交互查看热点函数
# 3. 实时 top 看热点
perf top
# 4. 查看可用事件
perf list
# 5. 分析具体事件(如缓存未命中)
perf stat -e cache-misses,cache-references ./app# 生成火焰图(定位 CPU 瓶颈)
perf record -g -F 99 ./app # 99Hz 采样
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
方法论:先 perf stat 看整体(IPC、cache miss、分支预测失败),再 perf record + report 看热点函数,最后结合火焰图定位到具体代码行。CPU 密集型看热点、IO 密集型看系统调用与阻塞。
程序变慢:先区分 CPU 瓶颈还是 IO/锁等待。CPU 高 → perf 找热点函数优化算法;缓存 miss 高 → 优化数据结构内存布局(如 std::vector 替代链表)。对比 gprof(插桩、需重编译),perf 无需改代码、开销低。
strace 的作用?
strace 跟踪进程发出的系统调用及其参数和返回值,用于定位「程序到底对内核做了什么」。
strace ./app # 跟踪所有系统调用
strace -f ./app # 跟踪 fork 出的子进程
strace -p 12345 # 附加到运行中进程
strace -e trace=open,read,write ./app # 只看指定调用
strace -e trace=network ./app # 网络相关
strace -c ./app # 统计各系统调用次数/耗时
strace -tt -o trace.log ./app # 带时间戳输出到文件典型输出:
open("/etc/ld.so.cache", O_RDONLY) = 3
open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY) = 3
read(3, "\177ELF\2\1\1\3...", 832) = 832
write(1, "hello\n", 6) = 6
能读 strace 输出:哪个文件打开失败(ENOENT)、哪个 read 阻塞、卡在 poll/epoll_wait 还是 futex(锁)。-c 统计能快速发现「哪个系统调用次数异常多」。
程序「卡住不动」→ strace 看它阻塞在哪个 syscall;「找不到文件/配置」→ 看 open 的路径与 ENOENT;「性能问题」→ -c 看 syscall 次数(如 read 只读 1 字节说明没缓冲)。
valgrind 如何检测内存问题?
valgrind 是一套动态分析工具,最常用 memcheck:在程序运行时插桩,检测非法内存访问、未初始化内存、内存泄漏等。无需改源码(但编译加 -g 便于定位行号)。
gcc -g -O0 -o app app.c
valgrind --leak-check=full --show-leak-kinds=all ./app
valgrind --tool=memcheck --track-origins=yes ./app # 追踪未初始化值来源能检测的问题:
- 越界读写(
Invalid read/write of size N) - 使用未初始化值(
Conditional jump depends on uninitialised value) - 重复 free / 非法 free
- 内存泄漏(
definitely lost/indirectly lost/possibly lost/still reachable)
#include <cstdlib>
int main() {
int* p = new int[10];
p[10] = 1; // 越界写
int* leak = new int[100]; // 泄漏
delete[] p;
return 0;
}valgrind --leak-check=full ./appdefinitely lost 是确定泄漏必须修;still reachable 是「全局指针仍指向」常可不处理。缺点:程序变慢 10~50 倍,不适合生产,主要用于开发/测试。
替代/互补工具:AddressSanitizer(-fsanitize=address,编译期插桩,更快、能检栈/全局溢出);LeakSanitizer;tcmalloc 的 heap profiler。valgrind 还能测 cache 行为(--tool=cachegrind)、多线程竞态(--tool=helgrind)。
gcc 编译的四个阶段?
gcc 从源码到可执行文件分四步,可用 -v 观察,或逐步手动执行。
gcc -v hello.c -o hello| 阶段 | 命令 | 输入 → 输出 |
|---|---|---|
| 1. 预处理 | gcc -E | .c → .i(展开宏、头文件、条件编译) |
| 2. 编译 | gcc -S | .i → .s(生成汇编) |
| 3. 汇编 | gcc -c | .s → .o(生成机器码目标文件) |
| 4. 链接 | gcc(默认) | .o + 库 → 可执行文件 |
gcc -E hello.c -o hello.i # 预处理
gcc -S hello.i -o hello.s # 编译成汇编
gcc -c hello.s -o hello.o # 汇编成目标文件
gcc hello.o -o hello # 链接// hello.c
#include <stdio.h>
#define MSG "hello"
int main() { printf("%s\n", MSG); return 0; }
编译阶段做词法/语法/语义分析、生成中间表示(GCC 用 GIMPLE/RTL)并优化;链接阶段做符号解析(symbol resolution)与重定位(relocation),把 printf 等外部符号地址填好。
大工程分模块编译:只重编改动文件(-c 生成 .o),最后统一链接,靠 make/cmake 管理依赖。-E 常用于排查宏展开、-S 查看优化后的汇编验证性能。
静态库和动态库的区别?如何生成?
| 维度 | 静态库 .a | 动态库 .so |
|---|---|---|
| 链接时机 | 编译期直接拷进可执行文件 | 运行时加载 |
| 产物体积 | 大(每个程序都含一份) | 小(多程序共享一份) |
| 运行依赖 | 无 | 依赖 .so 存在 |
| 更新 | 需重新编译链接 | 换 .so 即可(ABI 兼容前提) |
| 内存占用 | 每进程各自一份 | 物理页共享 |
生成静态库:
gcc -c add.c -o add.o
ar rcs libadd.a add.o # r=插入 c=创建 s=索引
gcc main.c -L. -ladd -o app # 链接: 找 libadd.a生成动态库:
gcc -c -fPIC add.c -o add.o # -fPIC 位置无关代码
gcc -shared -o libadd.so add.o
gcc main.c -L. -ladd -o app
./app # 可能报找不到 libadd.so
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH # 运行时搜索路径
# 或编译时写死 rpath: gcc main.c -L. -ladd -Wl,-rpath,$PWD
ldconfig # 更新系统共享库缓存 /etc/ld.so.conf
ldd app # 查看依赖的动态库-fPIC(位置无关代码)让 .so 能被加载到任意地址;动态链接通过 PLT(过程链接表)与 GOT(全局偏移表)实现延迟绑定(首次调用才解析地址)。符号版本/ABI 兼容是动态库升级的关键。
找不到 .so 三招:LD_LIBRARY_PATH、-Wl,-rpath、ldconfig。静态库依赖顺序:被依赖的库要放在后面(gcc main.o -la -lb,若 b 依赖 a 则 a 在 b 后)。
发布 SDK/库用动态库(可热更新、省内存);部署到「干净环境」为避免依赖问题用静态库;C++ 库注意 ABI(不同编译器/标准库版本可能不兼容)。
进程调度的算法有哪些?
Linux 调度器(scheduler)决定「哪个进程在哪个 CPU 上跑多久」,主要有两类调度类:
CFS(完全公平调度器,默认):普通进程 SCHED_OTHER。用 vruntime(虚拟运行时间)度量,每次选择 vruntime 最小的进程;vruntime 越小说明「得到的 CPU 越少」,保证公平。用红黑树维护,O(log n) 查找。
实时调度类:SCHED_FIFO(先到先服务,运行到阻塞/主动让出)、SCHED_RR(FIFO + 时间片轮转)。实时进程优先级永远高于普通进程。
#include <sched.h>
#include <unistd.h>
#include <cstdio>
int main() {
// 查看调度策略
int policy = sched_getscheduler(0);
printf("policy: %d (0=OTHER,1=FIFO,2=RR)\n", policy);
// 设置为 RR 实时调度(需 root 权限)
struct sched_param sp; sp.sched_priority = 50;
sched_setscheduler(0, SCHED_RR, &sp);
// 修改 nice 值(普通进程,-20 最高优先级)
nice(-5);
return 0;
}
还能补充:nice 值(-20~19)影响 CFS 权重、时间片长度;多核下还有负载均衡(把进程迁移到空闲 CPU);实时进程抢占普通进程;O(1) 调度器是 CFS 的前身。
音视频/机器人控制用 SCHED_FIFO/RR 保证实时响应;普通服务靠 CFS 公平共享 CPU;用 chrt 命令或 nice/renice 调整优先级。top 里 PR/NI 列反映优先级。
内核态和用户态?系统调用的过程?
x86 用特权级(Ring)隔离:Ring 0 = 内核态(可执行特权指令、访问所有内存),Ring 3 = 用户态(受限)。用户程序不能直接访问硬件/内核内存,必须通过系统调用进入内核。
系统调用过程(以 x86-64 为例):
- 用户态把系统调用号放入
rax,参数放入rdi/rsi/rdx/r10/r8/r9。 - 执行
syscall指令(x86-64;32 位用int 0x80),CPU 切换到 Ring 0。 - 内核根据调用号查系统调用表(
sys_call_table),跳转到对应内核函数。 - 内核执行、返回,CPU 切回 Ring 3。
#include <unistd.h>
#include <sys/syscall.h>
#include <cstdio>
int main() {
// 直接发起系统调用(glibc 的 write 底层就是它)
syscall(SYS_write, 1, "hello\n", 6);
return 0;
}strace -e trace=write ./app # 观察 write 系统调用
cat /proc/sys/kernel/ # 内核参数
系统调用会引发上下文切换,开销比普通函数调用大(约几百 ns 到 µs)。int 0x80(软中断,慢)已被 sysenter/syscall(快速系统调用,x86-64 用 syscall)取代。
频繁系统调用是性能瓶颈(strace -c 可见),故标准 IO 用缓冲、网络用批量 readv/writev、io_uring 用异步批量提交来减少 syscall 次数。
内存屏障和缓存一致性?
缓存一致性:多核 CPU 各自有 L1/L2 缓存,通过 MESI 协议(Modified/Exclusive/Shared/Invalid)保证各核看到的内存一致——写操作会使其它核对应缓存行失效(Invalid),保证最终读到最新值。
内存屏障(Memory Barrier/Fence):编译器或 CPU 会重排指令顺序优化性能,屏障用于约束这种重排,保证屏障前后的内存操作顺序可见。
#include <atomic>
std::atomic<int> data{0};
std::atomic<bool> ready{false};
// 写线程
void producer() {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release); // release:前面的写对后续可见
}
// 读线程
void consumer() {
while (!ready.load(std::memory_order_acquire)) {} // acquire
int v = data.load(std::memory_order_relaxed); // 保证读到 42
}// 编译器屏障(阻止编译器重排,非 CPU 屏障)
asm volatile("" ::: "memory");
// x86 上 CPU 屏障指令:mfence(全屏障)、lfence(读)、sfence(写)
__sync_synchronize(); // GCC 内建全屏障volatile 只保证「不优化掉该变量、每次从内存读」,不保证原子性、不保证有序性、不是内存屏障,多线程同步必须用 std::atomic 或互斥锁。memory_order 强弱排序(relaxed/acquire/release/seq_cst)是加分点。
无锁编程(lock-free 队列、std::atomic)依赖内存序;CPU 屏障用于设备驱动/DMA 与硬件交互。伪共享(false sharing):两个线程写同一缓存行不同变量会互相失效缓存,用 alignas(64) 对齐隔离。
make 和 cmake 的区别?
- make:读取
Makefile中的规则(目标、依赖、命令),判断哪些文件更新了、按依赖顺序执行编译命令。它只负责「按规则执行」,跨平台能力弱,规则要手写。 - cmake:更高层的构建系统生成器。读取
CMakeLists.txt,生成 Makefile(或 Ninja、Visual Studio 工程等),再交给 make/ninja 执行。抽象了编译器、平台差异,适合大工程与跨平台。
# Makefile
CC = g++
CFLAGS = -O2
app: main.o add.o
$(CC) -o app main.o add.o
main.o: main.cpp add.h
$(CC) $(CFLAGS) -c main.cpp
add.o: add.cpp add.h
$(CC) $(CFLAGS) -c add.cpp
clean:
rm -f *.o app# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(demo CXX)
add_executable(app main.cpp add.cpp) # 自动处理依赖
target_compile_options(app PRIVATE -O2)make -j8 # 并行构建
# CMake 常用流程
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j8
关系:cmake 生成 make 的规则文件,make 再执行。现代大项目多用 CMake + Ninja(更快)或 XMake/Meson。find_package、add_library、target_link_libraries 是 CMake 核心命令。
小项目一个 Makefile 够用;跨平台库(Windows/macOS/Linux)必用 CMake;可再结合 ccache 加速重复编译、-j 并行编译。
Linux 文件系统的 inode 是什么?软链接和硬链接有什么区别?
inode(索引节点)是 Linux 文件系统(如 ext4)里描述「文件元数据」的数据结构。一个文件 = inode + 数据块:inode 保存文件类型、权限、所有者、大小、时间戳(ctime/mtime/atime)、链接数、指向数据块的指针,但不含文件名;文件名存在目录项(dentry)里,目录项本质是一张「文件名 → inode 号」的映射表。
| 维度 | 硬链接(hard link) | 软链接(symbolic link) |
|---|---|---|
| inode | 与原文件同一个 inode | 独立新 inode,内容存「目标路径」 |
| 跨文件系统 | 不可以(同一 inode 属同一 FS) | 可以 |
| 链接目录 | 不允许(防止形成环) | 允许 |
| 删除原文件 | 仍可访问(链接数 -1,归 0 才删数据) | 失效,变「悬空链接」 |
| 本质 | 文件数据的别名 | 指向路径的「快捷方式」 |
touch a.txt
ls -i a.txt # 查看 inode 号(第一列)
stat a.txt # 查看完整 inode 元数据
ln a.txt a_hard.txt # 硬链接:链接数 +1,同一 inode
ln -s a.txt a_soft.txt # 软链接:新建 inode,指向 "a.txt"
ls -li a.txt a_hard.txt a_soft.txt # 对比 inode 号与链接数
rm a.txt # 删除原文件后:硬链接仍可读,软链接悬空
一个 inode 能对应多个文件名(硬链接),st_nlink 链接数降到 0 才真正释放数据块;软链接删除原文件后变成 dangling link(ls 红色闪烁)。mv 在同一文件系统内改的是目录项,inode 不变,故「打开着的文件被 mv 后仍可继续读写」。
硬链接不能跨文件系统、不能链接目录;软链接的权限是「始终可读」的(实际权限由目标文件决定)。find -inum 可按 inode 号找所有硬链接。
硬链接用于快照式备份、防止误删(数据多一个名字);软链接用于快捷方式、版本切换(/usr/bin/python3 -> python3.12)、部署发布(current -> releases/v1.2.0 原子切换)。
Linux 共享内存如何实现进程间通信?如何用信号量同步?
共享内存是最快的 IPC:多个进程把同一块物理内存映射到各自地址空间,读写无需在内核与用户态之间拷贝数据(对比管道/socket 要多次拷贝)。实现有 System V(shmget/shmat)与 POSIX(shm_open/mmap)两套接口,还有父子进程专用的匿名 mmap。
// POSIX 共享内存
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
int main() {
const char* name = "/my_shm";
int fd = shm_open(name, O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096); // 先定大小
char* p = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0); // 映射到本进程地址空间
strcpy(p, "hello from producer");
printf("written: %s\n", p);
munmap(p, 4096);
close(fd);
shm_unlink(name); // 删除共享内存对象
return 0;
}信号量是一个计数器,用于互斥/同步访问共享资源。POSIX 命名信号量 sem_open/sem_wait/sem_post:
#include <semaphore.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <cstdio>
#include <cstring>
#include <unistd.h>
int main() {
sem_t* sem = sem_open("/my_sem", O_CREAT, 0666, 1); // 初始值 1 = 互斥
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
char* p = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
sem_wait(sem); // P 操作:值-1,为 0 则阻塞
strcpy(p, "critical data"); // 临界区:多个进程互斥访问
sem_post(sem); // V 操作:值+1,唤醒等待者
munmap(p, 4096);
close(fd);
sem_close(sem);
sem_unlink("/my_sem");
shm_unlink("/my_shm");
return 0;
}
共享内存本身不提供同步,必须自己配信号量/互斥锁/条件变量;生产者-消费者可用「空位信号量 + 满位信号量」实现计数同步。POSIX 接口比 System V 更现代、易用,还支持 mmap 匿名共享。
大块数据共享(日志缓冲、图像帧、配置缓存)用共享内存,吞吐远高于管道;/dev/shm 是 tmpfs 内存文件系统,常被数据库/中间件当高性能缓存或共享内存载体。
MMU 和 TLB 的作用是什么?
MMU(Memory Management Unit,内存管理单元)是 CPU 内的硬件单元,负责把虚拟地址翻译成物理地址,同时做权限检查(页表项里的 R/W/X、U/S 位),非法访问触发缺页异常或 SIGSEGV。
TLB(Translation Lookaside Buffer,旁路转换缓冲)是 MMU 内部的高速缓存,缓存最近用过的「虚拟页号 → 物理页框号」映射(页表项)。地址翻译先查 TLB,命中则免去查多级页表的内存访问。
CPU 发出虚拟地址
│
▼
查 TLB ──命中(hit)──→ 直接得物理地址 → 访问内存
│未命中(miss)
▼
查多级页表(PGD→P4D→PUD→PMD→PTE) → 更新 TLB → 访问内存| 维度 | MMU | TLB |
|---|---|---|
| 作用 | 地址翻译 + 权限检查 | 缓存翻译结果,加速 |
| 本质 | 功能单元 | 高速缓存 |
| 未命中后果 | — | 额外查页表(更慢) |
| 与进程切换 | 换页表基址寄存器 | 进程切换常导致 TLB 失效(flush) |
#include <unistd.h>
#include <cstdio>
int main() {
// MMU 分页基本单位(通常 4KB)
printf("page size = %ld\n", sysconf(_SC_PAGESIZE));
return 0;
}grep Huge /proc/meminfo # 查看大页(HugePages)使用情况TLB miss 是性能热点,所以要用局部性友好布局;大页(Huge Page,2MB/1GB)让一个 TLB 项覆盖更大内存,显著减少 TLB miss。TLB 是每核独立的,进程切换换地址空间会导致 TLB 被刷新(overhead 来源之一,见 什么是上下文切换?进程切换和线程切换哪个开销大?)。
数据库(MySQL/PostgreSQL)、JVM 常开启大页减少 TLB miss 提升性能;虚拟化(KVM/QEMU)靠 MMU 的 EPT/NPT 做二层地址翻译,TLB miss 代价更高。
CPU 亲和性和 NUMA 是什么?如何设置?
CPU 亲和性(affinity):把进程/线程绑定到指定的 CPU 核上运行,避免线程在核间迁移导致的缓存(L1/L2)失效。用 sched_setaffinity 或 taskset 设置。
NUMA(Non-Uniform Memory Access,非统一内存访问):多路服务器把 CPU 和内存分成多个 node,每个 CPU 访问本地 node 内存快、访问远端 node 内存慢。对 NUMA 程序要尽量「本地内存优先」。
#define _GNU_SOURCE
#include <sched.h>
#include <cstdio>
int main() {
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(2, &set); // 绑定到 CPU 2
sched_setaffinity(0, sizeof(set), &set);
cpu_set_t get;
CPU_ZERO(&get);
sched_getaffinity(0, sizeof(get), &get);
printf("CPU2 是否在亲和集内: %d\n", CPU_ISSET(2, &get));
return 0;
}taskset -c 0,2 ./app # 让 app 只在 CPU 0、2 上跑
taskset -pc 1234 # 查看进程 1234 的亲和性
numactl --hardware # 查看 NUMA 拓扑(几个 node、各 node 内存)
numactl --cpunodebind=0 --membind=0 ./app # 绑定到 node 0 的 CPU 与本地内存| 维度 | CPU 亲和性 | NUMA |
|---|---|---|
| 关注点 | 线程 ↔ 特定核 | CPU/内存 ↔ node |
| 目的 | 减少跨核缓存失效、保持 cache 热 | 减少跨 node 远端访存 |
| 设置 | sched_setaffinity / taskset | numactl / mbind / set_mempolicy |
绑定亲和性不是万能的,可能破坏调度器负载均衡,适合延迟/缓存敏感场景;NUMA 下线程若被调度到远端 node 且内存留在本地,性能会明显下降(可用 numactl --membind 强制)。
高性能网络/数据库服务把关键工作线程绑定到独占核、内存绑本地 node;用 numastat 观察各 node 内存分配、taskset 做快速隔离压测。
进程的概念?进程有哪些状态?什么是进程控制块 PCB?
进程是「程序的一次执行实例」,是资源分配的基本单位,拥有独立的地址空间、文件描述符表、信号处理等(区别见 进程和线程的区别?Linux 下如何创建?)。
三种基本状态:就绪(Ready,可运行但没拿到 CPU)、运行(Running,正在 CPU 上执行)、阻塞(Blocked,等待事件/IO)。加上创建、终止,以及 Linux 特有的僵尸、停止等状态。
新建 ──→ 就绪 ──→ 运行 ──→ 终止
↑ │
└── 阻塞 ←┘Linux 下 ps 的 STAT 列对应状态:
| 状态码 | 含义 |
|---|---|
| R | 运行 / 就绪 |
| S | 可中断睡眠(等待事件/锁/IO) |
| D | 不可中断睡眠(通常在等磁盘 IO,不可被信号打断) |
| Z | 僵尸(已退出,父进程未回收) |
| T | 停止(被 SIGSTOP/调试器暂停) |
PCB(进程控制块):内核为每个进程维护的数据结构,Linux 中即 task_struct,保存进程状态、PID、寄存器现场、页表指针、打开文件表、调度信息、信号掩码等。内核调度、上下文切换都是围绕 PCB 展开。
ps aux # STAT 列看状态
ps -eo pid,stat,comm # 自定义输出
top # 实时查看 R/S/D 等#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) { while(1) sleep(10); } // 子进程:S(可中断睡眠)
else { while(1) {} } // 父进程:R(忙等占 CPU)
}
PCB 是进程在内核里的「身份证」,Linux 用 task_struct(/proc/<pid>/ 是其用户态投影);D 状态长期存在说明磁盘/网络 IO 异常。进程状态切换由调度器触发,见 进程调度的算法有哪些?。
排查进程「卡住」先看 STAT:S 可能在等锁/IO,D 在等磁盘,Z 是僵尸(见 僵尸进程和孤儿进程?如何避免?)。ps/top/pidstat 是常用观测工具。
线程的概念?用户级线程和内核级线程有什么区别?
线程是 CPU 调度的基本单位,共享所属进程的地址空间、打开文件,但拥有独立的栈、寄存器和程序计数器(区别见 进程和线程的区别?Linux 下如何创建?)。
按实现层次分两类:
| 维度 | 用户级线程(ULT) | 内核级线程(KLT) |
|---|---|---|
| 调度者 | 用户态线程库(内核无感知) | 内核直接调度 |
| 内核感知 | 视为一个进程 | 每个线程一个 task_struct |
| 切换开销 | 低(纯用户态,无系统调用) | 高(陷入内核) |
| 阻塞 | 一个线程阻塞会阻塞整个进程 | 一个阻塞不影响其它线程 |
| 多核并行 | 不能(内核只调度到进程) | 能真正并行 |
| 代表 | 协程、绿色线程(早期 Go、gevent) | pthread、Linux NPTL |
现代 Linux 采用 NPTL(1:1 模型):每个用户线程对应一个内核线程;也有 M:N 混合模型(多个用户线程映射到少量内核线程,如 Go 的 goroutine + GMP 调度)。
#include <pthread.h>
#include <cstdio>
void* run(void*) {
printf("线程 %lu 运行\n", (unsigned long)pthread_self());
return nullptr;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, nullptr, run, nullptr);
pthread_create(&t2, nullptr, run, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0;
}ps -eLf # -L 显示每个线程
top -H # 按线程查看内核级线程才是「真正的并发」;用户级线程(协程)的优势是切换快、可海量创建(百万级),适合 IO 密集。Go 的 goroutine 本质是 M:N 混合模型,把大量用户态协程调度到少量内核线程上。
CPU 密集用内核线程(pthread/std::thread)跑满多核;高并发 IO(如 Web 服务)用协程减少线程切换与栈开销。
什么是上下文切换?进程切换和线程切换哪个开销大?
上下文切换(context switch):CPU 从执行一个任务切换到另一个任务,需保存当前任务的现场(寄存器、程序计数器 PC、栈指针 SP、标志位等),再恢复新任务的现场。触发时机:时间片耗尽、主动阻塞/让出、被更高优先级抢占、系统调用返回等。
| 维度 | 进程切换 | 线程切换(同进程内) |
|---|---|---|
| 地址空间 | 切换页表基址、需刷新 TLB | 共享,不换页表 |
| 现场 | 完整 task_struct 现场 | 寄存器、栈、PC |
| 额外开销 | TLB flush、缓存变冷 | 较小 |
| 相对代价 | 大 | 小 |
vmstat 1 # cs 列 = 每秒上下文切换次数
pidstat -w 1 # cswch(自愿)/nvcswch(非自愿) 切换
perf stat -e context-switches ./app// 概念对照:频繁 fork 进程的切换开销,远大于线程池内线程切换
// 高并发场景应尽量用线程/协程,减少进程级上下文切换
进程切换贵在换页表基址 + 刷新 TLB(缓存失效);线程切换不换页表,但跨核迁移仍会丢缓存。切换频繁时看 vmstat 的 cs 过高 → 线程/进程过多或锁竞争,可减线程或改协程。
高并发服务(Nginx worker 进程 + 事件循环、线程池)本质都是在减少切换开销;锁竞争严重会引发大量非自愿切换(pidstat -w 的 nvcswch 飙升)。
多进程和多线程如何选择?各自的优缺点?
核心权衡是隔离性/稳定性 vs 通信效率/资源开销。
| 维度 | 多进程 | 多线程 |
|---|---|---|
| 稳定性/隔离 | 一个崩溃不影响其它 | 一个线程崩溃可能带崩进程 |
| 通信 | 需 IPC(管道/共享内存/信号,复杂、慢) | 直接读写共享内存(简单、快) |
| 创建/切换开销 | 大 | 小 |
| 资源占用 | 各自独立地址空间,内存大 | 共享地址空间,内存省 |
| 调试难度 | 相对简单 | 数据竞争、死锁难调试 |
选择原则:
- 强隔离、高稳定 → 多进程:如 Chrome 每个 tab 一个进程、Nginx 多 worker。
- 大量共享数据、频繁通信 → 多线程。
- CPU 密集 + 多核 → 两者皆可,多进程更安全。
- 海量 IO 并发 → 线程池 / 协程 / 事件驱动。
// 多线程(线程池)示例
#include <thread>
#include <vector>
#include <cstdio>
int main() {
std::vector<std::thread> pool;
for (int i = 0; i < 8; ++i)
pool.emplace_back([i]{ printf("worker %d\n", i); });
for (auto& t : pool) t.join();
return 0;
}常见还有混合模式:多进程 + 每进程内多线程(MySQL、PostgreSQL、浏览器),兼顾隔离与性能。回答时给出「根据场景取舍」的判断框架最加分。
数据库/中间件用多进程 + 线程池;Web 服务用进程隔离 + 事件循环(Nginx)。基础对比见 进程和线程的区别?Linux 下如何创建?。
什么是线程安全和可重入?它们有什么区别?
- 线程安全(thread-safe):多个线程并发调用同一函数/代码不会产生数据竞争或结果不一致。可通过加锁、原子操作、只用局部变量、不可变对象等实现。
- 可重入(reentrant):一个函数在被执行过程中可被中断(如信号、递归调用)后再次进入,且仍能正确执行。可重入函数不使用全局/静态数据、不调用不可重入函数、不修改自身代码。
关系:可重入 ⇒ 线程安全(可重入一定线程安全,反过来不一定)。
| 维度 | 线程安全 | 可重入 |
|---|---|---|
| 针对 | 多线程并发 | 中断/信号/递归再进入 |
| 实现手段 | 锁、原子、局部变量 | 不用全局/静态、不调不可重入 |
| 能否用锁 | 能 | 不能依赖锁(信号中断时可能死锁) |
| 严格程度 | 较宽 | 更严格 |
#include <cstdio>
// 不可重入:使用静态缓冲区,多次进入会互相覆盖
char* not_reentrant(int x) {
static char buf[32];
snprintf(buf, sizeof(buf), "%d", x);
return buf;
}
// 可重入:缓冲区由调用者提供,或使用局部变量
char* reentrant(int x, char* buf, size_t len) {
snprintf(buf, len, "%d", x);
return buf;
}// 线程安全但不一定可重入:用互斥锁保护全局变量
#include <mutex>
int counter = 0;
std::mutex m;
void safe_increment() {
std::lock_guard<std::mutex> lock(m);
++counter;
}
标准库 strtok/gmtime/localtime 不可重入,对应可重入版本 strtok_r/gmtime_r/localtime_r。信号 handler 里只能调用 async-signal-safe(可重入) 函数。errno 已改为线程局部变量(TLS)以保证线程安全。
写库函数/回调尽量可重入,避免调用方踩坑;多线程下优先用 _r 后缀函数或传入缓冲区,减少共享静态状态。
什么是守护进程?如何创建守护进程?
**守护进程(daemon)**是后台长期运行、脱离控制终端的进程,如 sshd、nginx、systemd、cron。特点:无控制终端、父进程为 PID 1、默默提供服务。
创建步骤(经典 setsid 法):
fork后父进程退出,让子进程不是进程组组长(为setsid做准备)。- 子进程调用
setsid()创建新会话,成为会话首进程,脱离终端。 - 再次
fork并退出,让孙进程不是会话首进程,永远无法重新获得终端。 chdir("/")改工作目录(避免占用将被卸载的目录)。umask(0)重置文件权限掩码。- 关闭/重定向标准 IO(
close(0/1/2)或重定向到/dev/null)。
#include <unistd.h>
#include <cstdlib>
#include <fcntl.h>
void daemonize() {
pid_t pid = fork();
if (pid < 0) exit(1);
if (pid > 0) _exit(0); // 1. 父进程退出
if (setsid() < 0) exit(1); // 2. 新会话,脱离终端
pid = fork();
if (pid < 0) exit(1);
if (pid > 0) _exit(0); // 3. 孙进程不是会话首进程
chdir("/"); // 4. 改工作目录
umask(0); // 5. 重置权限掩码
int fd = open("/dev/null", O_RDWR); // 6. 重定向标准 IO
dup2(fd, 0); dup2(fd, 1); dup2(fd, 2);
if (fd > 2) close(fd);
}
int main() {
daemonize();
while (1) {
// 后台服务逻辑
sleep(10);
}
return 0;
}ps -eo pid,ppid,stat,cmd | grep -E 'sshd|nginx|cron' # 观察 daemon:PPID=1、无 TTY
systemctl status nginx # systemd 管理的守护进程
现代 Linux 建议用 systemd 管理守护进程(写 .service unit 文件),自动处理脱离终端、日志收集、崩溃重启;glibc 提供的 daemon() 函数是老式封装,功能等价但灵活性差。
自研服务脱离终端后台运行、日志重定向到文件或 syslog;配合 fork 的孤儿/僵尸处理(见 僵尸进程和孤儿进程?如何避免?)保证进程干净。