线程
一、线程基础
1.1 线程是什么?
- 「进程」:操作系统分配资源(内存空间、文件描述符、信号表...)的最小单位。
- 「线程」:操作系统「调度执行」的最小单位。同一进程的多个线程共享:
- 但每个线程「独立拥有」:线程 id、栈、寄存器、信号掩码、
errno、线程私有数据(pthread_key)
直观对比:
| | |
|---|
| | |
| 更重(切换页表、刷新 TLB、刷新 caches 的概率大) | |
| 复杂:管道、FIFO、mmap、socket、信号 | |
| | |
| | |
1.2 基本 lifecycle
#include<pthread.h>#include<stdio.h>#include<unistd.h>// 推荐用法:函数指针写法staticvoid *worker(void *arg){int id = *(int *)arg;printf("thread %d start\n", id); usleep(100 * 1000);printf("thread %d end\n", id);// 方式一:返回值;主线程通过 pthread_join 获取// 注意:只能返回永存的值;静态、malloc 或 (void*)(long)staticint ret = 42;return (void *)(long)ret;}intmain(void){pthread_t tid;int id = 1;// 创建线程 pthread_create(&tid, NULL, worker, &id);// 回收线程void *ret = NULL; pthread_join(tid, &ret);printf("thread returned %ld\n", (long)ret);return0;}
几个易踩坑的点:
arg 的生命周期必须长于线程。不要传栈地址给不 join 的线程,除非你保证栈还在。pthread_join 会阻塞等待指定线程结束;不调用会变成 「"zombie 线程"」,资源和 PID 不回收。pthread_detach 告诉系统"我不管这个线程了,它结束就自动回收"。例如:pthread_detach(pthread_self)。- 线程函数里不要做
return &main_stack_value,应该 malloc 一个、或返回全局/静态变量、或强转 (void*)(long)。
1.3 线程的数据类型
| |
|---|
| pthread_t |
| pthread_once_t |
| pthread_mutex_t |
| pthread_rwlock_t |
| pthread_cond_t |
| pthread_spinlock_t |
| pthread_key_t |
| sem_t |
| pthread_barrier_t |
1.4 Cancel 与 Cleanup
// 主线程中取消子线程pthread_cancel(tid);// 子线程定义清理动作(被 cancel 时自动调用)// push / pop 必须是配对出现在同一函数、同一大括号的"宏"pthread_cleanup_push(cleanup_handler, arg);// ...pthread_cleanup_pop(execute); // 0: 只弹出; 非 0: 调用 handler
Cancel 实际上是"请求",不是强制。默认是 「deferred cancel」:只在遇到取消点函数(read、write、pthread_cond_wait、sleep、sem_wait...)时才退出。可以用 pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL) 改为异步,但「容易在持有锁时被杀死」,导致后续所有阻塞在该锁上的线程永久卡住。
工程实践里我更偏爱"主动退出"替代 cancel:让线程轮询一个 atomic flag,看到就主动退出、统一走清理逻辑。这样可以避免异步 cancel 的资源泄漏和锁遗留问题。
二、同步原语(Synchronization Primitives)
2.1 为什么需要同步
共享内存天生可见,但「可见性、原子性、顺序性」三个问题会导致:
- 「竞态」(race condition):两个线程同时读写同一变量,典型如
counter++ - 「数据撕裂」(torn read/write):写操作被中断,对方读到一半旧一半新
- 「编译器重排」:优化后实际执行顺序不一定是你写的顺序
- 「CPU 重排」:弱内存序架构(ARM/Power)上观察到的顺序和你写的完全不同
C11 stdatomic.h、C23 atomic、GCC __atomic_* / __sync_* 都能帮你解决可见性 + 原子 + memory-order 问题。但在"同步原语"这个话题里,我们通常把"互斥锁 / 条件变量 / 读写锁 / 信号量 / 自旋锁 / 栅栏"这些负责"协调与等待"的机制称作同步原语。
2.2 互斥锁 pthread_mutex_t
// 静态初始化(PTHREAD_MUTEX_INITIALIZER)staticpthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;// 或者运行时初始化(推荐用于动态分配的锁 / 需要设置属性的锁)pthread_mutexattr_t attr;pthread_mutexattr_init(&attr);pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_ERRORCHECK); // 或 RECURSIVE / NORMALpthread_mutex_init(&mtx, &attr);pthread_mutexattr_destroy(&attr);pthread_mutex_lock(&mtx);// 临界区pthread_mutex_unlock(&mtx);// 用完销毁pthread_mutex_destroy(&mtx);
典型模式:
staticpthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;staticint counter = 0;intinc(void){ pthread_mutex_lock(&mtx);int new_val = ++counter; // 锁住 Read-Modify-Write pthread_mutex_unlock(&mtx);return new_val;}// 易错点:在锁外直接读 counter 拿到的可能是过期值;只在锁内保证可见性
类型:
PTHREAD_MUTEX_NORMAL:非递归锁,同线程加锁两次立刻死锁PTHREAD_MUTEX_RECURSIVE:递归锁,支持同一线程重复加锁(内部维护锁计数 + owner)PTHREAD_MUTEX_ERRORCHECK:加锁两次立刻返回 EDEADLK,项目 debug 期首选
适用场景:
2.3 读写锁 pthread_rwlock_t
staticpthread_rwlock_t rw = PTHREAD_RWLOCK_INITIALIZER;// 读锁:多个线程可以同时持有pthread_rwlock_rdlock(&rw);/* 读共享数据 */pthread_rwlock_unlock(&rw);// 写锁:独占pthread_rwlock_wrlock(&rw);/* 修改共享数据 */pthread_rwlock_unlock(&rw);
适用场景:读 >> 写,例如「缓存表」、「路由表」、「全局 lookup 表」。
缺点:
- 「写饥饿」:很多实现偏向读锁,如果读线程一直涌来,写线程可能长期得不到锁
- 成本:实现比 mutex 更复杂,「在短临界区或 write 频繁的场景,反而比 mutex 慢」
工程建议:先上 mutex,性能出现瓶颈且 profiling 证实是读争用主导,再改用读写锁。
2.4 条件变量 pthread_cond_t
条件变量「没有状态」,它只做一件事:「让线程等待,直到另一个线程通知它"条件可能变了"」。
staticpthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;staticpthread_cond_t cond = PTHREAD_COND_INITIALIZER;staticint done = 0;// 等待者pthread_mutex_lock(&mtx);while (!done) { // 必须是 while:避免虚假唤醒 pthread_cond_wait(&cond, &mtx); // 解锁并睡;醒来前重新加锁}/* 这时可以安全地访问 done 和共享数据 */pthread_mutex_unlock(&mtx);// 通知者pthread_mutex_lock(&mtx);done = 1;pthread_cond_broadcast(&cond); // 唤醒所有等待者// 或: pthread_cond_signal(&cond); // 唤醒一个等待者pthread_mutex_unlock(&mtx);
要点:
while(!cond) 不是 if(!cond):避免「虚假唤醒」(spurious wake-up)和「信号被偷吃」(偷跑线程在 signal 之后一把抢到锁)pthread_cond_wait 会把提供的 mutex 「解锁+挂起」,归来时「重新带着锁」signal:唤醒一个等待者;适合多消费者等一个资源broadcast:唤醒所有;适合等待"任务队列非空"的 worker 池
2.5 信号量 sem_t
更泛化的同步原语:有 N 个 permits,sem_wait 申请一个,sem_post 释放一个。
- 可以当互斥锁用:
sem_init(&sem, 0, 1) - 也可以当条件变量用:配合 mutex 表示"事件计数"
- 特别契合生产者-消费者;最大好处是「没有 owner 概念」,任何线程都能
post
sem_t sem;sem_init(&sem, 0, 0); // pshared=0 表示线程间共享;value=0 表示初始没有资源// 生产sem_post(&sem);// 消费(阻塞直到有资源)sem_wait(&sem);
2.6 自旋锁 pthread_spinlock_t
pthread_spinlock_t spin;pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);pthread_spin_lock(&spin);/* 临界区极短 */pthread_spin_unlock(&spin);pthread_spin_destroy(&spin);
和 mutex 的区别:mutex 会 syscall futex、阻塞唤醒让出 CPU;spinlock 只在用户态 CAS / lock cmpxchg 循环空转。
适用场景:
- 不舍得付出「上下文切换的开销」(通常 1-10us)
- 「单核慎用」:等待者占住 CPU,持有者反而得不到调度
2.7 栅栏 pthread_barrier_t
让一组线程「都到达某一阶段后一起出发」,典型的分阶段算法 / 多线程 benchmark。
pthread_barrier_t barrier;pthread_barrier_init(&barrier, NULL, N); // 需要 N 个线程到达// 在所有 worker 的阶段交界处pthread_barrier_wait(&barrier);
例如 map-reduce:N 个 map worker 把数据分片并行处理,到 reduce 阶段必须等所有 map 完成。
2.8 一次性初始化 pthread_once
staticpthread_once_t once = PTHREAD_ONCE_INIT;staticstructconfig *global_cfg;staticvoidinit_cfg(void){ global_cfg = load_config("/etc/app.conf"); }struct config *get_cfg(void){ pthread_once(&once, init_cfg);return global_cfg;}
等价于 C11 的 call_once,非常适合全局单例的"懒初始化"。
2.9 避免 deadlock 的铁律
- 「固定的全局加锁顺序」:所有线程以相同顺序加 A->B->C 锁;反过来会死锁
- 「设置超时」:
pthread_mutex_timedlock、sem_timedwait - 「用 lock hierarchy」:把锁编号,文档 + 代码层面保持一致
- 「静态分析工具」:
helgrind / TSan 能检测数据竞争与死锁
三、三个经典问题(面试"三件套")
3.1 生产者-消费者(Bounded Buffer)
#define N 16staticint buffer[N];staticint head = 0, tail = 0, count = 0;staticpthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;staticpthread_cond_t not_full = PTHREAD_COND_INITIALIZER;staticpthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;voidproduce(int value){ pthread_mutex_lock(&mtx);while (count == N) pthread_cond_wait(¬_full, &mtx); // 满则等待 buffer[head] = value; head = (head + 1) % N; count++; pthread_cond_signal(¬_empty); // 通知消费者 pthread_mutex_unlock(&mtx);}intconsume(void){ pthread_mutex_lock(&mtx);while (count == 0) pthread_cond_wait(¬_empty, &mtx);int v = buffer[tail]; tail = (tail + 1) % N; count--; pthread_cond_signal(¬_full); pthread_mutex_unlock(&mtx);return v;}
要点:
- 锁保护「判断」和「入队/出队」,保护判定和状态修改的原子性
- 每个条件变量绑定对应的「状态含义」:
not_full=有空间,not_empty=有数据
3.2 读者-写者
// 读者优先版staticpthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;staticpthread_cond_t write_ok = PTHREAD_COND_INITIALIZER;staticint readers_active = 0;staticint writer_waiting = 0;staticint writers_active = 0;voidread_lock(void){ pthread_mutex_lock(&mtx);while (writers_active || writer_waiting) pthread_cond_wait(&write_ok, &mtx); readers_active++; pthread_mutex_unlock(&mtx);}voidwrite_lock(void){ pthread_mutex_lock(&mtx); writer_waiting++;while (readers_active || writers_active) pthread_cond_wait(&write_ok, &mtx); writer_waiting--; writers_active = 1; pthread_mutex_unlock(&mtx);}voidread_unlock(void){ pthread_mutex_lock(&mtx); readers_active--;if (readers_active == 0) pthread_cond_broadcast(&write_ok); pthread_mutex_unlock(&mtx);}voidwrite_unlock(void){ pthread_mutex_lock(&mtx); writers_active = 0; pthread_cond_broadcast(&write_ok); pthread_mutex_unlock(&mtx);}
可以改造为"写者优先"或"公平版":核心差异在于 「reads_active 和 writes_active 上的等待策略」。
3.3 哲学家就餐
#define P 5staticpthread_mutex_t chopsticks[P];voidphilosopher(int i){int l = i, r = (i + 1) % P;// 打破死锁的方法之一:统一先拿较小编号的筷子if (l > r) { int tmp = l; l = r; r = tmp; } pthread_mutex_lock(&chopsticks[l]); pthread_mutex_lock(&chopsticks[r]); eat(); pthread_mutex_unlock(&chopsticks[r]); pthread_mutex_unlock(&chopsticks[l]);}
其他常见解法:资源分级、仲裁者(master 限制 N-1 人同时拿筷子)、奇偶侧策略。
四、服务器场景下的线程与同步
4.1 服务器架构的演化脉络
blocking I/O + one-thread-per-connection (PREFORK) | 成千上万连接时上下文切换爆炸,栈内存放大 vmulti-process: prefork (Apache httpd) | 进程重量大,共享内存麻烦 vI/O multiplexing + single-threaded loop (Redis / Nginx 简化版) | CPU 密集型处理阻塞 loop,不友好超线程 vI/O multiplexing + thread pool (主流现代方案) | vio_uring + io_workqueue (较新路线,更快但生态年轻)
4.2 模式对比
4.3 为什么服务器场景必须考虑同步
| |
|---|
| |
| |
| |
| |
| |
| mutex + cond 或 短 spinlock |
| |
4.4 经典"主线程 accept + 工作线程处理"
#include<pthread.h>#include<unistd.h>#include<stdio.h>#include<stdlib.h>#include<netinet/in.h>#include<sys/socket.h>#define PORT 8080#define THREADS 4staticint listen_fd;staticpthread_t workers[THREADS];staticvoid *worker(void *arg){ (void)arg;for (;;) {// accept 在单 listen_fd 上是线程安全的(kernel 内会串行化并避免 thundering-herd)int fd = accept(listen_fd, NULL, NULL);if (fd < 0) continue;char buf[4096];ssize_t n;while ((n = read(fd, buf, sizeof(buf))) > 0) { write(fd, buf, n); // echo } close(fd); }returnNULL;}intmain(void){ listen_fd = socket(AF_INET, SOCK_STREAM, 0);int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));structsockaddr_ina = { .sin_family = AF_INET, .sin_port = htons(PORT), .sin_addr.s_addr = htonl(INADDR_ANY), }; bind(listen_fd, (struct sockaddr *)&a, sizeof(a)); listen(listen_fd, 128);for (int i = 0; i < THREADS; i++) pthread_create(&workers[i], NULL, worker, NULL);for (int i = 0; i < THREADS; i++) pthread_join(workers[i], NULL);}
这个模式有两个明显瓶颈:
- 突发流量:某个 Worker 被「长连接或慢连接」 block,其他 Worker 闲置——线程模型对「长尾不均」非常敏感。
- 高并发下 kernel 内
listen_lock 争用:accept() 本身有 listen 锁,高接受率时成为热点(可引入 SO_REUSEPORT 多 listen socket 分摊)。
可改进的方向:
SO_REUSEPORT 让多个线程各自 listen 自己的 socket 分摊 accept- epoll + non-blocking + edge-triggered 替代阻塞模型
- 结合
eventfd + 派发队列实现 「Reactor + worker pool」
4.5 epoll + eventfd + worker pool(较高级的现代骨架)
main thread (Reactor)┌──────────────────────────────────────────────────────────────────────┐│ epoll_wait(listen_fd, clientfd*, eventfd, timerfd, signalfd...) ││ │ new conn : accept -> epoll_add ││ │ client ready : read request -> task_enqueue(worker_pool) ││ │ timerfd fired : run scheduled tasks ││ │ eventfd : worker 上报完成,主线程集中 epoll_mod OUT │└──────────────────────────────────────────────────────────────────────┘ | v task queue + broadcast/eventfd┌──────────────────────────────────────────────────────────────────────┐│ worker threads (business logic) ││ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││ │ worker 0 │ │ worker 1 │ │ worker 2 │ │ worker 3 │ ... ││ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │└──────────────────────────────────────────────────────────────────────┘
这样拆分后:
- Reactor 只负责「I/O + 事件调度」,不被 CPU 密集任务卡住
- Worker 只负责「业务逻辑」,可与 CPU 核心数线性扩展
- 临界区只在跨线程队列上(经典的 prod-cons)
这是经典的 「Leader/Followers + Reactor」 组合,也是很多真实系统的骨干。
五、I/O 多路复用:select / poll / epoll
5.1 为什么需要多路复用
普通阻塞模型下一个线程只能处理一个 fd;要做到"一个线程管理多个 fd",只有三条路:
- 「请操作系统在任意 fd 就绪时通知你」 → 多路复用
典型三段式:「注册兴趣 fd」 → 「阻塞等待任意就绪」 → 「迭代就绪集并处理」
5.2 select() 快速上手
#include<sys/select.h>#include<unistd.h>#include<stdio.h>intmain(void){ fd_set rfds;while (1) { FD_ZERO(&rfds); FD_SET(STDIN_FILENO, &rfds);structtimevaltv = { .tv_sec = 5, .tv_usec = 0 };int ret = select(STDIN_FILENO + 1, &rfds, NULL, NULL, &tv);if (ret < 0) { perror("select"); break; }if (ret == 0) { printf("timeout\n"); continue; }if (FD_ISSET(STDIN_FILENO, &rfds)) {char buf[256];ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));printf("got %zd bytes\n", n); } }return0;}
5.3 poll()——只是稍微好了点
#include<poll.h>#include<stdio.h>#include<unistd.h>intmain(void){structpollfdfds[2] = { { .fd = STDIN_FILENO, .events = POLLIN }, { .fd = sock_fd, .events = POLLIN }, };while (1) {int ret = poll(fds, 2, 5000); // 5 秒超时if (ret > 0) {for (int i = 0; i < 2; i++) {if (fds[i].revents & POLLIN) {char buf[256];ssize_t n = read(fds[i].fd, buf, sizeof(buf));printf("[%d] got %zd bytes\n", fds[i].fd, n); } } } }}
poll 没有 1024 限制,但仍是线性扫描。数千 fd 时已经吃力,数万 fd 后 O(n) 让吞吐量雪崩。
5.4 epoll——Linux 下的"主流王"
#include<sys/epoll.h>#include<unistd.h>#include<stdio.h>#define MAX_EVENTS 1024intmain(void){int epfd = epoll_create1(EPOLL_CLOEXEC);if (epfd < 0) { perror("epoll_create1"); return1; }structepoll_eventev = { .events = EPOLLIN, // 默认 LT;加 EPOLLET 切换到 edge .data.fd = STDIN_FILENO, }; epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);structepoll_eventevents[MAX_EVENTS];while (1) {int n = epoll_wait(epfd, events, MAX_EVENTS, -1);for (int i = 0; i < n; i++) {int fd = events[i].data.fd;if (events[i].events & (EPOLLERR | EPOLLHUP)) { close(fd); continue; }char buf[4096];ssize_t len;while ((len = read(fd, buf, sizeof(buf))) > 0) {printf("got %zd bytes\n", len); } } } close(epfd);return0;}
5.5 LT vs ET
| | | |
|---|
| 只要处于可读/可写状态,epoll_wait 就一直报告默认行为 | | |
| 「状态从未就绪→就绪时」 | | 必须一次「读/写到 EAGAIN」;否则后续事件消失;必须搭配 non-blocking |
经验:默认用 LT。当你已经做到以下三点时再切 ET:
while(read/write) 直到 EAGAIN
5.6 epoll 经典使用场景
场景 1:聊天室 / websocket bridge
单线程 epoll 主循环负责「读写 + 广播」;CPU 密集任务(json、cpu 编码)走 worker pool。主循环之内没有锁,天然清爽。
场景 2:反向代理 / VPN 转发
clientfd 与 upstreamfd 分别注册 epoll,在「双向就绪驱动」下做数据搬运:
[ clientfd readable ] -> write to upstreamfd[ upstreamfd readable ] -> write to clientfd
epoll 天然支撑**"代理服务" = 双向就绪驱动**。
场景 3:数据库 / KV 客户端
- MySQL client / libpq 都支持 non-blocking
- Redis、memcached、leveldb、rocksdb 的 NIO 客户端几乎都基于 epoll 驱动
场景 4:混合事件源(都是 fd)
把 eventfd(跨线程通知)、timerfd(高精度定时)、signalfd(把信号变成 fd 事件)也挂在 epoll 下,就能形成「统一事件驱动的异步主循环」:
// listen_fd, client_fds, eventfd, timerfd, signalfd 全部走同一个 epoll_wait
这是高性能服务器"少锁、多核、多连接"的关键魔法之一。
5.7 select / poll / epoll 选型建议
- < 1000 fd、不想绑定 Linux:
poll - 想尝鲜 + Linux 5.1+:
io_uring - 现代 C++ / Rust:直接用
libevent、libev、libuv、boost.asio(它们内部也是 select / poll / epoll / kqueue / iocp)
六、"多路复用 + 多线程":现代高性能服务器骨架
6.1 Reactor + Worker Pool
关键点:
- 「1 个 Reactor 线程」(也有 ≥ 1,绑定不同核):管理 epoll,「只做 I/O + 事件调度」
- 「N 个 Worker 线程」:负责「业务逻辑」(CPU 密集或短阻塞)
- 通信:「Task Queue(prod-cons)」 +
eventfd 跨线程唤醒 epoll
Reactor thread:┌─────────────────┐│ epoll_wait ... ││ new_conn -> accept & epoll_add│ read req -> task_enqueue(worker_pool)│ writable -> flush response└─────────────────┘ | v task queue┌──────────────────────────────────────────────┐│ worker threads ││ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ││ │ T0 │ │ T1 │ │ T2 │ │ T3 │ ... ││ └──────┘ └──────┘ └──────┘ └──────┘ │└──────────────────────────────────────────────┘
这样一种服务器「同时拥有」:
- I/O 密集部分 = 多连接 epoll(线性扩展到 1w+ 连接)
- CPU 密集部分 = 多 Worker(线性利用多核)
6.2 多 Reactor(SubReactor)
多线程同时 epoll(通常配合 SO_REUSEPORT 让内核把连接亲和绑定到某线程):
- 每个 Reactor 固定负责自己的一组连接(线程封闭)
- 跨 Reactor 的消息走 「lock-free ring」(DPDK
rte_ring、LMAX Disruptor 风格的 ring buffer)
典型用途:高吞吐网关、金融交易、CDN 边缘缓存。
6.3 Thread-per-core + DPDK(了解)
某些极致场景(10G+ / 100G 网卡、低延迟交易):彻底不用内核协议栈 + epoll,自己绑核、用户态 TCP/IP(DPDK、mTCP、F-Stack)。思路核心:
- 「每核独占一组资源」(run-to-completion,cache 友好)
- 用 polling 替代 interrupt 驱动(CPU burn 换延迟)
代价:开发复杂度、兼容性、可调试性都大幅升高,适合"吞吐指标远超一般业务"的细分领域。
七、线程 + 多路复用联合下的易错点
| | |
|---|
| | |
线程 A 的 fd 被线程 B 直接 epoll_ctl | race,EPOLL_CTL_MOD 在历史上非原子 | 把 DEL/ADD 操作收拢到 Reactor 线程统一执行 |
| 多线程同时 epoll_wait 监听同一组 fd | 「thundering herd」 | EPOLLEXCLUSIVE(Linux 4.5+)或单 Reactor 模式 |
| | 强制 non-blocking,并读到 EAGAIN / 写到 EAGAIN |
| | 唯一写 owner,或加锁保护写操作,或 IO 线程统一写 |
八、调试 / 性能工具小抄
| |
|---|
perf top | |
strace -c -p <pid> | |
valgrind --tool=helgrind | |
valgrind --tool=drd | |
ThreadSanitizer (TSan) | 更高的性能竞争检测(-fsanitize=thread) |
bpftrace | |
htop | |