📋 摘要

进程和线程的区别?Linux 下如何创建?

进程是资源分配的基本单位,拥有独立的地址空间、文件描述符表、信号处理等;线程是 CPU 调度的基本单位,共享进程的地址空间、打开的文件,但拥有独立的栈、寄存器和程序计数器。

维度进程线程
地址空间独立共享
创建/切换开销大(fork 复制页表等)小(只新建栈与 TCB)
通信方式需 IPC(管道/共享内存/信号)直接读写共享内存
崩溃影响一个进程崩溃不影响其它一个线程崩溃可能带崩整个进程

Linux 下创建进程用 fork()(或 vfork/posix_spawn),创建线程用 pthread_create;二者底层都走 clone() 系统调用,只是传入的标志不同。

C++
#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 的开销被大幅降低,也避免了大量无用拷贝。

C++
#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。
C++
#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;
}

检查僵尸进程:

Bash
ps aux | grep Z      # STAT 列为 Z 即僵尸
top                  # 顶部显示 "zombie" 数量

避免方法:

C++
// 方法一:显式 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 释放。

C++
#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。

C++
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,从而减少系统调用次数,并自动做换行符等处理。
C++
#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)含物理页号、存在位、权限位、脏位等。

Text
虚拟地址 = 页号(P) | 页内偏移(D)
  │ 查页表
  ▼
物理地址 = 物理页框号 | 页内偏移(D)

多级页表(x86-64 用 4 级:PGD→P4D→PUD→PMD→PTE)解决单级页表过大问题;TLB(Translation Lookaside Buffer)缓存最近翻译结果,加速查表。

C++
#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 少了一次「内核缓冲区 → 用户缓冲区」的拷贝(零拷贝读)。

C++
#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;
}

适用场景:

  1. 大文件随机访问:只映射需访问的页,按需换入,避免 lseek + 多次 read。
  2. 进程间共享内存(IPC):MAP_SHARED | MAP_ANONYMOUS,父子进程或无关进程共享。
  3. malloc 大块分配:glibc 大内存用匿名 mmap。
  4. 零拷贝 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),流程:

  1. MMU 产生 page fault 异常,陷入内核,保存现场。
  2. 内核查 do_page_fault:判断地址是否合法(属于该进程的合法区间),非法则发 SIGSEGV(段错误)。
  3. 查找该虚拟页对应的后备存储:文件映射页 / 匿名页 / swap 分区。
  4. minor fault(次缺页):页在物理内存(如被其它进程共享),只需建立映射、更新 PTE。
  5. major fault(主缺页):页不在内存,需从磁盘(文件或 swap)读入,分配页框、拷贝数据。
  6. 更新页表、刷新 TLB,返回用户态,重新执行那条触发异常的指令。
C++
#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 观测缺页:

Bash
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 归还内核。
C++
#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)。

常见信号:

信号值含义能否捕获
SIGINT2Ctrl+C 中断可
SIGTERM15终止(优雅退出)可
SIGKILL9强杀不可捕获/忽略
SIGSEGV11段错误(非法内存访问)可(不推荐)
SIGPIPE13向关闭的管道写可
SIGCHLD17子进程状态改变可
SIGSTOP/SIGCONT19/18停止/继续不可捕获
C++
#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 保留符号信息。

Bash
gcc -g -O0 -o app main.c     # -g 调试信息, -O0 关优化便于调试
gdb ./app                    # 启动
gdb --args ./app arg1        # 带参数
gdb -p 12345                 # 附加到运行中的进程

核心命令:

Bash
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
Bash
# 崩溃后分析 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工具。

Bash
# 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
Bash
# 生成火焰图(定位 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 跟踪进程发出的系统调用及其参数和返回值,用于定位「程序到底对内核做了什么」。

Bash
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   # 带时间戳输出到文件

典型输出:

Text
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 便于定位行号)。

Bash
gcc -g -O0 -o app app.c
valgrind --leak-check=full --show-leak-kinds=all ./app
valgrind --tool=memcheck --track-origins=yes ./app   # 追踪未初始化值来源

能检测的问题:

  1. 越界读写(Invalid read/write of size N)
  2. 使用未初始化值(Conditional jump depends on uninitialised value)
  3. 重复 free / 非法 free
  4. 内存泄漏(definitely lost / indirectly lost / possibly lost / still reachable)
C++
#include <cstdlib>
int main() {
    int* p = new int[10];
    p[10] = 1;          // 越界写
    int* leak = new int[100];  // 泄漏
    delete[] p;
    return 0;
}
Bash
valgrind --leak-check=full ./app
💡 面试加分项

definitely lost 是确定泄漏必须修;still reachable 是「全局指针仍指向」常可不处理。缺点:程序变慢 10~50 倍,不适合生产,主要用于开发/测试。

ℹ️ 实际应用场景

替代/互补工具:AddressSanitizer(-fsanitize=address,编译期插桩,更快、能检栈/全局溢出);LeakSanitizer;tcmalloc 的 heap profiler。valgrind 还能测 cache 行为(--tool=cachegrind)、多线程竞态(--tool=helgrind)。


gcc 编译的四个阶段?

gcc 从源码到可执行文件分四步,可用 -v 观察,或逐步手动执行。

Bash
gcc -v hello.c -o hello
阶段命令输入 → 输出
1. 预处理gcc -E.c → .i(展开宏、头文件、条件编译)
2. 编译gcc -S.i → .s(生成汇编)
3. 汇编gcc -c.s → .o(生成机器码目标文件)
4. 链接gcc(默认).o + 库 → 可执行文件
Bash
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        # 链接
C++
// 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 兼容前提)
内存占用每进程各自一份物理页共享

生成静态库:

Bash
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

生成动态库:

Bash
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 + 时间片轮转)。实时进程优先级永远高于普通进程。

C++
#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 为例):

  1. 用户态把系统调用号放入 rax,参数放入 rdi/rsi/rdx/r10/r8/r9。
  2. 执行 syscall 指令(x86-64;32 位用 int 0x80),CPU 切换到 Ring 0。
  3. 内核根据调用号查系统调用表(sys_call_table),跳转到对应内核函数。
  4. 内核执行、返回,CPU 切回 Ring 3。
C++
#include <unistd.h>
#include <sys/syscall.h>
#include <cstdio>
int main() {
    // 直接发起系统调用(glibc 的 write 底层就是它)
    syscall(SYS_write, 1, "hello\n", 6);
    return 0;
}
Bash
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 会重排指令顺序优化性能,屏障用于约束这种重排,保证屏障前后的内存操作顺序可见。

C++
#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
}
C++
// 编译器屏障(阻止编译器重排,非 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
# 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
cmake
# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(demo CXX)
add_executable(app main.cpp add.cpp)   # 自动处理依赖
target_compile_options(app PRIVATE -O2)
Bash
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 才删数据)失效,变「悬空链接」
本质文件数据的别名指向路径的「快捷方式」
Bash
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。

C++
// 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:

C++
#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,命中则免去查多级页表的内存访问。

Text
CPU 发出虚拟地址
   │
   ▼
查 TLB ──命中(hit)──→ 直接得物理地址 → 访问内存
   │未命中(miss)
   ▼
查多级页表(PGD→P4D→PUD→PMD→PTE) → 更新 TLB → 访问内存
维度MMUTLB
作用地址翻译 + 权限检查缓存翻译结果,加速
本质功能单元高速缓存
未命中后果—额外查页表(更慢)
与进程切换换页表基址寄存器进程切换常导致 TLB 失效(flush)
C++
#include <unistd.h>
#include <cstdio>
int main() {
    // MMU 分页基本单位(通常 4KB)
    printf("page size = %ld\n", sysconf(_SC_PAGESIZE));
    return 0;
}
Bash
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 程序要尽量「本地内存优先」。

C++
#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;
}
Bash
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 / tasksetnumactl / mbind / set_mempolicy
💡 面试加分项

绑定亲和性不是万能的,可能破坏调度器负载均衡,适合延迟/缓存敏感场景;NUMA 下线程若被调度到远端 node 且内存留在本地,性能会明显下降(可用 numactl --membind 强制)。

ℹ️ 实际应用场景

高性能网络/数据库服务把关键工作线程绑定到独占核、内存绑本地 node;用 numastat 观察各 node 内存分配、taskset 做快速隔离压测。


进程的概念?进程有哪些状态?什么是进程控制块 PCB?

进程是「程序的一次执行实例」,是资源分配的基本单位,拥有独立的地址空间、文件描述符表、信号处理等(区别见 进程和线程的区别?Linux 下如何创建?)。

三种基本状态:就绪(Ready,可运行但没拿到 CPU)、运行(Running,正在 CPU 上执行)、阻塞(Blocked,等待事件/IO)。加上创建、终止,以及 Linux 特有的僵尸、停止等状态。

Text
  新建 ──→ 就绪 ──→ 运行 ──→ 终止
              ↑       │
              └── 阻塞 ←┘

Linux 下 ps 的 STAT 列对应状态:

状态码含义
R运行 / 就绪
S可中断睡眠(等待事件/锁/IO)
D不可中断睡眠(通常在等磁盘 IO,不可被信号打断)
Z僵尸(已退出,父进程未回收)
T停止(被 SIGSTOP/调试器暂停)

PCB(进程控制块):内核为每个进程维护的数据结构,Linux 中即 task_struct,保存进程状态、PID、寄存器现场、页表指针、打开文件表、调度信息、信号掩码等。内核调度、上下文切换都是围绕 PCB 展开。

Bash
ps aux                # STAT 列看状态
ps -eo pid,stat,comm  # 自定义输出
top                   # 实时查看 R/S/D 等
C++
#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 调度)。

C++
#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;
}
Bash
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、缓存变冷较小
相对代价大小
Bash
vmstat 1                          # cs 列 = 每秒上下文切换次数
pidstat -w 1                      # cswch(自愿)/nvcswch(非自愿) 切换
perf stat -e context-switches ./app
C++
// 概念对照:频繁 fork 进程的切换开销,远大于线程池内线程切换
// 高并发场景应尽量用线程/协程,减少进程级上下文切换
💡 面试加分项

进程切换贵在换页表基址 + 刷新 TLB(缓存失效);线程切换不换页表,但跨核迁移仍会丢缓存。切换频繁时看 vmstat 的 cs 过高 → 线程/进程过多或锁竞争,可减线程或改协程。

ℹ️ 实际应用场景

高并发服务(Nginx worker 进程 + 事件循环、线程池)本质都是在减少切换开销;锁竞争严重会引发大量非自愿切换(pidstat -w 的 nvcswch 飙升)。


多进程和多线程如何选择?各自的优缺点?

核心权衡是隔离性/稳定性 vs 通信效率/资源开销。

维度多进程多线程
稳定性/隔离一个崩溃不影响其它一个线程崩溃可能带崩进程
通信需 IPC(管道/共享内存/信号,复杂、慢)直接读写共享内存(简单、快)
创建/切换开销大小
资源占用各自独立地址空间,内存大共享地址空间,内存省
调试难度相对简单数据竞争、死锁难调试

选择原则:

  1. 强隔离、高稳定 → 多进程:如 Chrome 每个 tab 一个进程、Nginx 多 worker。
  2. 大量共享数据、频繁通信 → 多线程。
  3. CPU 密集 + 多核 → 两者皆可,多进程更安全。
  4. 海量 IO 并发 → 线程池 / 协程 / 事件驱动。
C++
// 多线程(线程池)示例
#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):一个函数在被执行过程中可被中断(如信号、递归调用)后再次进入,且仍能正确执行。可重入函数不使用全局/静态数据、不调用不可重入函数、不修改自身代码。

关系:可重入 ⇒ 线程安全(可重入一定线程安全,反过来不一定)。

维度线程安全可重入
针对多线程并发中断/信号/递归再进入
实现手段锁、原子、局部变量不用全局/静态、不调不可重入
能否用锁能不能依赖锁(信号中断时可能死锁)
严格程度较宽更严格
C++
#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;
}
C++
// 线程安全但不一定可重入:用互斥锁保护全局变量
#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 法):

  1. fork 后父进程退出,让子进程不是进程组组长(为 setsid 做准备)。
  2. 子进程调用 setsid() 创建新会话,成为会话首进程,脱离终端。
  3. 再次 fork 并退出,让孙进程不是会话首进程,永远无法重新获得终端。
  4. chdir("/") 改工作目录(避免占用将被卸载的目录)。
  5. umask(0) 重置文件权限掩码。
  6. 关闭/重定向标准 IO(close(0/1/2) 或重定向到 /dev/null)。
C++
#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;
}
Bash
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 的孤儿/僵尸处理(见 僵尸进程和孤儿进程?如何避免?)保证进程干净。