第12章:并发编程
并发与并行,傻傻分不清楚?
导读
"免费的午餐结束了!"——2005年,Herb Sutter(C++ 标准委员会成员)发表了那篇著名的文章《The Free Lunch Is Over》,宣告了一个时代的终结。在过去几十年里,程序员们享受着一种隐性的"免费午餐":每当硬件升级,程序不用修改就能自动变快,因为 CPU 的时钟频率在不断提升。但从 2004 年前后开始,单核 CPU 的频率提升撞上了物理极限(功耗墙、散热问题),处理器厂商转向了另一条道路——多核。
多核时代早已到来。你的手机有 4 到 8 个核心,你的笔记本电脑有 4 到 16 个核心,服务器更是动辄几十个核心。但多核带来的不是"免费的性能提升",而是编程范式的根本转变。想要压榨出多核处理器的全部性能,软件必须能够同时利用多个核心——这就是并发编程。
然而,并发(Concurrency)和并行(Parallelism)是两个经常被混淆的概念。Google 的 Rob Pike(Go 语言之父)曾做过一个精彩的演讲来区分它们:并发是程序的架构设计——将程序拆分为多个可独立运行的部分;并行是程序的执行方式——多个计算任务在同一时刻同时运行。并发可能会用到并行,但不一定要用并行。并发关注的是结构,并行关注的是执行。
打个比方:一个人一边喝咖啡一边看报纸,这是并发(一个人同时处理两件事,但注意力在两者之间切换);两个人一起搬一张桌子,这是并行(两个人在同一时刻共同完成一项工作)。并发程序可以在单核上运行(通过时间片切换),也可以多核上并行运行。
本章将介绍三种并发编程模型:基于进程的并发、基于 I/O 多路复用的并发和基于线程的并发。我们将深入讨论多线程编程中的核心挑战——竞态条件、死锁、数据不一致——以及对应的同步原语:互斥锁、信号量、读写锁。最后,我们将学习两个经典的并发模型:生产者-消费者模型和读者-写者模型。
核心概念详解
一、基于进程的并发
最直观的并发方式是使用进程。通过 fork() 系统调用,一个进程可以创建自己的副本(子进程),父子进程共享相同的代码但拥有独立的地址空间,因此可以"同时"执行不同的任务。我们在第8章(异常控制流)中已经详细学习了 fork() 的工作机制,这里我们从并发的角度重新审视它。
基于进程的并发的核心思想是:主进程(父进程)作为"主逻辑"运行,每当需要处理一个新的并发任务时,就 fork() 一个子进程来执行。子进程独立运行,与父进程和其他子进程互不干扰(因为它们有独立的地址空间)。这种隔离性既是优点也是缺点。
优点:进程之间天然隔离,一个进程的崩溃不会影响其他进程。编程模型简单——不需要考虑共享数据的同步问题,因为每个进程都有自己的数据副本。错误处理也相对简单——子进程退出后,父进程可以通过 waitpid() 获取其退出状态。
缺点:进程间通信(IPC)开销大。因为进程有独立的地址空间,它们不能直接共享数据,必须通过管道(pipe)、消息队列、共享内存、Socket 等 IPC 机制来交换信息,这些机制都有额外的系统调用和内存拷贝开销。进程的创建和销毁成本也较高——fork() 需要复制父进程的页表和进程描述符(虽然 Copy-On-Write 优化减少了实际内存拷贝),进程切换涉及完整的地址空间切换(TLB 刷新等),上下文切换代价大。
一个典型的基于进程的并发应用是传统的 CGI Web 服务器。每当收到一个 HTTP 请求,服务器就 fork() 一个子进程来处理该请求。这种模型实现简单,但性能受限——在高并发场景下(如 C10K 问题——同时处理一万个连接),创建上万个进程的内存和 CPU 开销是不可接受的。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
int main() {
signal(SIGCHLD, sigchld_handler);
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("Child %d (PID=%d) running\n", i, getpid());
sleep(2);
printf("Child %d done\n", i);
exit(0);
}
printf("Forked child %d (PID=%d)\n", i, pid);
}
printf("Parent waiting for children...\n");
while (waitpid(-1, NULL, 0) > 0)
;
printf("All children done\n");
return 0;
}上面的代码展示了基于进程的并发的基本模式:父进程 fork() 多个子进程,子进程并行执行各自的任务,父进程通过 waitpid() 等待所有子进程完成。注意 SIGCHLD 信号处理函数的使用——它确保子进程退出后被及时回收,避免产生僵尸进程。处理函数中使用 WNOHANG 选项的循环是因为多个子进程可能同时退出,而信号可能会被合并(Unix 信号不排队)。
二、基于 I/O 多路复用的并发
I/O 多路复用(I/O Multiplexing)是另一种并发模型,它的核心思想是:在单个进程内,使用一个循环来监控多个 I/O 描述符(Socket、文件描述符等),当某个描述符准备好可以进行 I/O 操作时,才去处理它。这样,单个进程就可以"同时"管理多个 I/O 连接,而不需要创建多个进程或线程。
Linux 提供了三种 I/O 多路复用机制:select()、poll() 和 epoll()。select() 是最古老的方式,它将所有监控的描述符放在一个集合(fd_set)中,调用后返回哪些描述符已经准备好。但 select() 有诸多限制:最大描述符数通常为 1024(FD_SETSIZE),每次调用都需要将整个描述符集合从用户态拷贝到内核态,返回后需要遍历所有描述符找到就绪的那些——时间复杂度 O(n)。poll() 使用链表代替位图,突破了 1024 的限制,但 O(n) 的遍历开销依然存在。
epoll() 是 Linux 特有的高效 I/O 多路复用机制,它解决了 select() 和 poll() 的所有痛点。epoll 在内核中维护一个就绪列表,只有已经就绪的描述符才会出现在返回结果中,时间复杂度 O(1)(就单个描述符的通知而言)。epoll 支持两种触发模式:水平触发(Level-Triggered, LT,默认)和边缘触发(Edge-Triggered, ET)。LT 模式下,只要描述符有数据可读就会持续通知;ET 模式下,只有状态变化时(从无数据变为有数据)才通知一次,效率更高但编程更复杂。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#define MAX_EVENTS 64
#define PORT 8080
int main() {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
listen(listenfd, 128);
int epfd = epoll_create1(0);
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
printf("Event-driven server on port %d\n", PORT);
while (1) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listenfd) {
int connfd = accept(listenfd, NULL, NULL);
ev.events = EPOLLIN;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
printf("New connection, fd=%d\n", connfd);
} else {
int fd = events[i].data.fd;
char buf[1024];
int n = read(fd, buf, sizeof(buf));
if (n <= 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
printf("Connection closed, fd=%d\n", fd);
} else {
write(fd, buf, n);
}
}
}
}
return 0;
}上面的代码展示了一个基于 epoll 的事件驱动回显服务器。它在单个进程内同时管理所有连接:主循环调用 epoll_wait() 等待事件,当有新的连接事件时调用 accept() 接受连接并注册到 epoll;当已有连接有数据可读时,读取数据并回显。这种模型的优势在于极低的资源开销——不需要创建进程或线程,没有上下文切换的代价,可以轻松处理数万个并发连接。Nginx、Redis、Node.js 等高性能服务器都采用了这种模型。
但 I/O 多路复用也有其局限性:它只适用于 I/O 密集型的场景(大量连接但每个连接的计算量很小)。如果每个连接的处理涉及大量 CPU 计算(如图像处理、加密解密),单个进程会阻塞在计算上,无法及时响应其他连接的 I/O 事件。这时就需要结合线程池(见下文)来实现计算和 I/O 的分离。
三、基于线程的并发
线程(Thread)是介于进程和函数之间的抽象。一个线程运行在进程的上下文(地址空间、文件描述符表、信号处理器等)中,但它有自己的独立调用栈和线程本地存储。同一个进程中的多个线程共享该进程的全部虚拟地址空间——包括代码、数据、堆、共享库和打开的文件描述符。这种"共享一切"的特性使得线程间通信极其高效(直接读写共享变量即可,不需要 IPC),但也引入了并发编程的核心挑战:同步。
POSIX 线程(pthreads)是 Unix/Linux 系统中标准的线程编程接口。创建线程使用 pthread_create(),等待线程结束使用 pthread_join(),线程主动退出使用 pthread_exit()。与进程不同,同一进程内的所有线程共享相同的全局变量和堆数据,因此一个线程对共享数据的修改对其他线程立即可见。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#define NUM_THREADS 4
void *thread_func(void *arg) {
int id = *(int *)arg;
printf("Thread %d (TID=%lu) started\n", id, (unsigned long)pthread_self());
sleep(1);
printf("Thread %d done\n", id);
return NULL;
}
int main() {
pthread_t tids[NUM_THREADS];
int ids[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
ids[i] = i;
pthread_create(&tids[i], NULL, thread_func, &ids[i]);
}
for (int i = 0; i < NUM_THREADS; i++) {
pthread_join(tids[i], NULL);
}
printf("All threads completed\n");
return 0;
}线程模型相比进程模型的优势在于:创建和销毁线程的开销远小于进程(不需要复制地址空间);线程间切换的开销远小于进程切换(不需要切换地址空间,TLB 不需要刷新);线程间共享数据无需 IPC 机制,直接读写共享内存即可。
但"共享一切"也是线程模型最大的风险来源。当多个线程同时读写同一个共享变量时,如果不加控制,就会产生竞态条件(Race Condition)——程序的最终结果取决于线程执行的时序,而这种时序往往是不确定的、不可重现的。这就是为什么同步机制如此重要。
四、互斥锁与同步原语
互斥锁(Mutex)是最基本的同步原语。它的语义非常简单:每个互斥锁有两个状态——锁定(locked)和解锁(unlocked)。当一个线程持有了锁(locked),其他试图获取同一把锁的线程会被阻塞,直到持有者释放锁(unlock)。这保证了在任何时刻,最多只有一个线程能进入由互斥锁保护的临界区(Critical Section)。
在 POSIX 线程中,互斥锁的使用方式如下:用 pthread_mutex_init() 初始化互斥锁;用 pthread_mutex_lock() 获取锁(如果锁已被其他线程持有,调用线程会阻塞);用 pthread_mutex_unlock() 释放锁;用 pthread_mutex_destroy() 销毁互斥锁。
#include <stdio.h>
#include <pthread.h>
int counter = 0;
pthread_mutex_t mutex;
void *increment(void *arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_mutex_init(&mutex, NULL);
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Final counter value: %d\n", counter);
pthread_mutex_destroy(&mutex);
return 0;
}上面的代码展示了一个经典的竞态条件场景。counter++ 看似是一条语句,实际上包含三个步骤:从内存读取 counter 的值到寄存器、寄存器值加一、将新值写回内存。如果两个线程同时执行 counter++,可能出现以下交错:线程A读取 counter=5,线程B也读取 counter=5(因为A还没写回),A写回 6,B也写回 6——本应加两次变成 7,结果只有 6。这就是为什么需要互斥锁来保护临界区。
使用互斥锁需要注意几个关键问题。死锁(Deadlock):如果线程 A 持有锁 1 并等待锁 2,线程 B 持有锁 2 并等待锁 1,两个线程永远无法继续——这就是死锁。避免死锁的经典方法是按固定顺序获取锁——如果所有线程都先获取锁 1 再获取锁 2,就不会出现循环等待。锁的粒度:锁保护的范围越大越安全,但并发性越低。理想情况下,每个锁只保护最小的必要数据单元。性能开销:获取和释放互斥锁涉及原子操作和可能的线程阻塞/唤醒,有不可忽视的性能开销。在高并发场景下,如果多个线程频繁竞争同一把锁,锁本身可能成为性能瓶颈。
除了互斥锁,还有其他同步原语。读写锁(Read-Write Lock)允许多个读者同时访问共享数据,但写者独占。适用于读多写少的场景。条件变量(Condition Variable)允许线程在某个条件不满足时休眠等待,当条件满足时被其他线程唤醒。自旋锁(Spinlock)在获取锁失败时不阻塞线程,而是让线程不断循环重试("自旋"),适用于锁持有时间极短的场景,可以避免线程切换的开销。
五、信号量
信号量(Semaphore)是由荷兰计算机科学家 Dijkstra 发明的一种同步原语,它比互斥锁更加通用。一个信号量维护一个整数计数器,支持两个原子操作:P 操作(也叫 wait、down、sem_wait)将计数器减一,如果计数器变为负数则阻塞;V 操作(也叫 signal、up、sem_post)将计数器加一,如果有线程因 P 操作而阻塞则唤醒一个。
信号量的计数器含义取决于使用场景。当信号量初始化为 1 时,它等价于一个互斥锁(二元信号量),用于保护临界区。当信号量初始化为 N 时,它可以控制同时访问某个资源的线程数量不超过 N(计数信号量)。当信号量用于线程间的事件通知时(初始化为 0),它就是一个同步工具——一个线程通过 P 操作等待某个事件,另一个线程通过 V 操作通知事件已发生。
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
sem_t sem;
void *worker(void *arg) {
int id = *(int *)arg;
printf("Worker %d waiting for signal...\n", id);
sem_wait(&sem);
printf("Worker %d received signal, working...\n", id);
sleep(1);
printf("Worker %d done\n", id);
return NULL;
}
int main() {
sem_init(&sem, 0, 0);
pthread_t t1, t2, t3;
int ids[] = {1, 2, 3};
pthread_create(&t1, NULL, worker, &ids[0]);
pthread_create(&t2, NULL, worker, &ids[1]);
pthread_create(&t3, NULL, worker, &ids[2]);
sleep(1);
printf("Main: sending signals...\n");
sem_post(&sem);
sem_post(&sem);
sem_post(&sem);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
sem_destroy(&sem);
return 0;
}信号量的强大之处在于它的灵活性——同一个机制可以用于互斥、同步、资源计数等多种场景。但灵活性也意味着更容易出错。忘记 V 操作会导致永久阻塞;P 和 V 操作的顺序错误可能导致死锁。因此,在实际编程中,通常优先使用更高级的同步工具(如互斥锁 + 条件变量、管程等),信号量主要用于实现这些高级工具或解决特定的同步问题。
六、生产者-消费者模型
生产者-消费者模型(Producer-Consumer Model)是并发编程中最经典、最实用的并发模型之一。它描述了一种常见的场景:一组生产者线程生成数据项并放入缓冲区,一组消费者线程从缓冲区取出数据项并处理。缓冲区充当了生产者和消费者之间的解耦层——生产者不需要知道消费者是谁、有多少个、处理多快;消费者也不需要知道数据是谁生产的、何时生产的。
这个模型几乎出现在所有需要"生产数据 → 处理数据"的场景中:Web 服务器接收请求(生产者)并分发给工作线程处理(消费者);视频播放器从网络下载数据(生产者)并解码播放(消费者);日志系统中各模块写入日志(生产者),日志线程将日志写入磁盘(消费者);消息队列系统(Kafka、RabbitMQ)本质上就是分布式的生产者-消费者模型。
实现生产者-消费者模型的关键挑战是同步:当缓冲区满时,生产者必须等待;当缓冲区空时,消费者必须等待。同时,对缓冲区的访问必须是线程安全的(互斥)。经典的实现方式是使用一个有界缓冲区(Bounded Buffer)配合两个信号量:一个 slots 信号量记录可用的空位数(初始化为缓冲区大小 N),一个 items 信号量记录已有的数据项数(初始化为 0)。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
#define BUF_SIZE 5
#define NUM_PRODUCERS 2
#define NUM_CONSUMERS 3
int buf[BUF_SIZE];
int fill = 0, use = 0;
int count = 0;
sem_t slots, items;
pthread_mutex_t mutex;
void put(int value) {
buf[fill] = value;
fill = (fill + 1) % BUF_SIZE;
count++;
}
int get() {
int value = buf[use];
use = (use + 1) % BUF_SIZE;
count--;
return value;
}
void *producer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < 10; i++) {
sem_wait(&slots);
pthread_mutex_lock(&mutex);
put(id * 100 + i);
printf("Producer %d produced %d (count=%d)\n", id, id * 100 + i, count);
pthread_mutex_unlock(&mutex);
sem_post(&items);
usleep(rand() % 100000);
}
return NULL;
}
void *consumer(void *arg) {
int id = *(int *)arg;
for (int i = 0; i < 10; i++) {
sem_wait(&items);
pthread_mutex_lock(&mutex);
int value = get();
printf(" Consumer %d consumed %d (count=%d)\n", id, value, count);
pthread_mutex_unlock(&mutex);
sem_post(&slots);
usleep(rand() % 200000);
}
return NULL;
}
int main() {
sem_init(&slots, 0, BUF_SIZE);
sem_init(&items, 0, 0);
pthread_mutex_init(&mutex, NULL);
pthread_t ptids[NUM_PRODUCERS], ctids[NUM_CONSUMERS];
int pids[NUM_PRODUCERS], cids[NUM_CONSUMERS];
for (int i = 0; i < NUM_PRODUCERS; i++) {
pids[i] = i + 1;
pthread_create(&ptids[i], NULL, producer, &pids[i]);
}
for (int i = 0; i < NUM_CONSUMERS; i++) {
cids[i] = i + 1;
pthread_create(&ctids[i], NULL, consumer, &cids[i]);
}
for (int i = 0; i < NUM_PRODUCERS; i++)
pthread_join(ptids[i], NULL);
for (int i = 0; i < NUM_CONSUMERS; i++)
pthread_join(ctids[i], NULL);
printf("All done. Final count: %d\n", count);
sem_destroy(&slots);
sem_destroy(&items);
pthread_mutex_destroy(&mutex);
return 0;
}这个实现有几个值得注意的细节。首先,slots 和 items 信号量的 P/V 操作顺序很重要:生产者先 P(slots) 再 P(mutex),消费者先 P(items) 再 P(mutex)。如果反过来(先获取 mutex 再等待信号量),会导致死锁——因为持有 mutex 的同时阻塞,其他线程无法获取 mutex 来释放信号量。其次,互斥锁只保护对缓冲区的实际操作(put()/get()),信号量的 P 操作在互斥锁之外——这样可以在缓冲区满/空时让出 CPU,而不是占着锁等待。
生产者-消费者模型在现代软件架构中无处不在。线程池(Thread Pool)本质上就是一个生产者-消费者系统:主线程将请求放入任务队列(生产者),工作线程从队列中取出任务执行(消费者)。消息队列中间件(Kafka、RabbitMQ、Redis List)是分布式版本的生产者-消费者模型。理解这个模型,是理解现代并发系统架构的基础。
七、线程安全与常见陷阱
编写正确的并发程序极其困难。以下是一些常见的线程安全问题及其应对策略。
竞态条件(Race Condition):多个线程对共享数据进行"读取-修改-写入"操作时,最终结果取决于线程调度顺序。解决方案:使用互斥锁保护临界区,或使用原子操作(__sync_fetch_and_add() 等)。
死锁(Deadlock):两个或多个线程互相等待对方持有的锁,导致所有线程都无法继续。产生死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。破坏其中任何一个条件即可避免死锁。实践中最常用的方法是按固定顺序获取锁。
活锁(Livelock):线程没有阻塞,但在不断重试中永远无法取得进展。例如,两个线程都试图获取同一把锁,失败后等待随机时间再重试,但总是恰好同时重试。解决方案:引入随机退避(Random Backoff)。
数据不一致:忘记加锁、锁的范围不对、或者使用了错误的同步原语,导致共享数据处于不一致状态。这类 bug 极难调试,因为它们可能只在特定时序下出现(Heisenbug)。
线程泄漏:创建了线程但没有正确 join 或 detach,导致线程资源无法释放。
可重入性(Reentrancy):一个函数如果在被中断后重新进入仍然能正确工作,就是可重入的。标准 C 库中有些函数不是线程安全的(如 strtok() 使用静态缓冲区,localtime() 返回静态结构体的指针),应使用其线程安全版本(strtok_r()、localtime_r())。
编写线程安全代码的最佳实践:尽量减少共享状态——如果数据不需要在多个线程之间共享,就不要共享。不可变数据(Immutable Data)天然线程安全——如果数据创建后不再修改,多个线程同时读取不会有任何问题。线程本地存储(Thread-Local Storage, TLS)让每个线程拥有变量的独立副本,避免了共享。消息传递(Message Passing)用通信代替共享——Go 语言的哲学"Don't communicate by sharing memory; share memory by communicating"就是这个思路。如果必须共享,使用成熟的同步原语(互斥锁、信号量、条件变量),并遵循既定的设计模式(生产者-消费者、读者-写者等)。
实验解读
并发编程实践:从串行到并行
理解并发编程最好的方式是亲手将一个串行程序改造为并发版本。以一个简单的文件处理任务为例:假设有 100 个文件需要进行某种转换处理(如压缩、加密、格式转换),串行版本依次处理每个文件;并发版本使用线程池同时处理多个文件。
串行版本:
for (int i = 0; i < 100; i++) {
process_file(files[i]);
}基于线程池的并发版本:
#define NUM_WORKERS 4
sem_t jobs_slots, jobs_items;
pthread_mutex_t jobs_mutex;
char *job_queue[100];
int job_fill = 0, job_use = 0;
void *worker_thread(void *arg) {
while (1) {
sem_wait(&jobs_items);
pthread_mutex_lock(&jobs_mutex);
char *file = job_queue[job_use];
job_use = (job_use + 1) % 100;
pthread_mutex_unlock(&jobs_mutex);
sem_post(&jobs_slots);
if (file == NULL) break;
process_file(file);
}
return NULL;
}
int main() {
sem_init(&jobs_slots, 0, 100);
sem_init(&jobs_items, 0, 0);
pthread_mutex_init(&jobs_mutex, NULL);
pthread_t workers[NUM_WORKERS];
for (int i = 0; i < NUM_WORKERS; i++) {
pthread_create(&workers[i], NULL, worker_thread, NULL);
}
for (int i = 0; i < 100; i++) {
sem_wait(&jobs_slots);
pthread_mutex_lock(&jobs_mutex);
job_queue[job_fill] = files[i];
job_fill = (job_fill + 1) % 100;
pthread_mutex_unlock(&jobs_mutex);
sem_post(&jobs_items);
}
for (int i = 0; i < NUM_WORKERS; i++) {
sem_wait(&jobs_slots);
pthread_mutex_lock(&jobs_mutex);
job_queue[job_fill] = NULL;
job_fill = (job_fill + 1) % 100;
pthread_mutex_unlock(&jobs_mutex);
sem_post(&jobs_items);
}
for (int i = 0; i < NUM_WORKERS; i++) {
pthread_join(workers[i], NULL);
}
return 0;
}这个改造展示了并发编程的几个核心模式:任务队列(解耦任务生产和消费)、线程池(控制并发度,避免创建过多线程)、优雅退出(通过发送 NULL 标记通知工作线程退出)。在 4 核机器上,这个并发版本理论上可以获得接近 4 倍的性能提升。但实际加速比往往低于理论值,因为存在锁竞争、线程切换、I/O 瓶颈等开销。
CSAPP 的并发实验(Concurrency Lab)通常要求学生实现一个线程安全的动态内存分配器,或者一个并发的 Web 服务器。这些实验的核心挑战不在于实现功能本身,而在于确保在任意线程调度顺序下程序都能正确工作——这需要你深入理解同步原语的语义,仔细分析所有可能的交错执行路径。
延伸阅读
- 《Programming with POSIX Threads》(David R. Butenhof):POSIX 线程编程的经典教材,详细讲解了线程创建、同步、死锁避免等核心话题。
- 《The Art of Multiprocessor Programming》(Maurice Herlihy & Nir Shavit):多处理器编程的艺术,深入讲解了并发数据结构和同步算法的理论基础。
- CMU 15-418/15-618 并行计算架构与编程:卡内基梅隆大学的并行计算课程,B 站有中文字幕视频。课程涵盖了 GPU 编程、SIMD、任务并行、数据并行等现代并行计算的核心话题。
- Rob Pike 的演讲《Concurrency Is Not Parallelism》:Google I/O 2012 上的经典演讲,用生动的"地鼠"比喻解释了并发与并行的区别。Go 语言的 goroutine 和 channel 设计也深受这个演讲的影响。
- 《Java Concurrency in Practice》(Brian Goetz):虽然是 Java 语言,但书中关于线程安全、可见性、发布与逸出、死锁等话题的讨论对所有语言的并发编程都有参考价值。
- Herb Sutter 的文章《The Free Lunch Is Over》:这篇 2005 年发表的文章预言了多核时代的到来及其对软件开发的深远影响,至今读来仍然发人深省。