第七章:多线程并发服务器——pthread库入门、线程同步与实战
各位同学,欢迎来到第七章。从这一章开始,我们正式进入多线程的世界。说实话,单线程的服务器就像只有一个收银员的超市——高峰期排队排到门外去。我早年做第一个并发项目时,用的就是多进程,fork 了一堆子进程,结果内存开销大得吓人。后来换成多线程,才真正体会到什么叫「轻量级并发」。
好,咱们今天就把 pthread 库、互斥锁、条件变量,以及怎么用它们搭一个多线程服务器,一次性讲透。
7.1 pthread 库入门:创建与等待线程
pthread 是 POSIX 标准下的线程库,Linux 上直接用。说白了,线程就是轻量级进程,共享同一块地址空间。你想想看,多个线程能直接访问同一个全局变量,这比进程间通信(管道、共享内存)方便太多了。
先看一个最简单的例子:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void* thread_func(void* arg) {
int* num = (int*)arg;
printf("子线程收到参数: %d\n", *num);
return NULL;
}
int main() {
pthread_t tid;
int val = 42;
// 创建线程
if (pthread_create(&tid, NULL, thread_func, &val) != 0) {
perror("pthread_create");
exit(1);
}
// 等待线程结束
pthread_join(tid, NULL);
printf("主线程结束\n");
return 0;
}
这里有几个关键点:
pthread_create的第四个参数是传给线程函数的参数,我习惯传结构体指针,这样能带多个数据。pthread_join会阻塞主线程,直到子线程退出。如果不 join,主线程一结束,整个进程就没了,子线程会被强制终止。- 线程函数的返回值是
void*,你可以用pthread_join的第二个参数拿到这个返回值。
-lpthread 链接线程库。我当年第一次编译忘了加,报了一堆 undefined reference,还以为是代码写错了……
7.2 线程同步:互斥锁(Mutex)
多线程共享全局变量,听起来很美,但坑也在这里。多个线程同时写一个变量,结果会怎样?我举个例子:两个线程同时执行 counter++,你以为加了两次,实际上可能只加了一次。为什么?因为 counter++ 不是原子操作——它分三步:读、加、写。两个线程的读写交错在一起,数据就乱了。
解决办法就是互斥锁。说白了,就是给共享资源加把锁,同一时间只有一个线程能碰它。
#include <pthread.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;
void* worker(void* arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&lock);
shared_counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
嗯,这里要注意:锁的粒度要适中。锁得太细(比如每条指令都加锁),性能下降;锁得太粗(整个函数加锁),又失去了并发的意义。我在项目中遇到过一种情况:一个线程持有锁后去调用外部接口,结果外部接口超时,锁一直被占着,其他线程全部卡死。后来我加了个超时机制,用 pthread_mutex_timedlock 解决了。
goto 统一出口,或者用 RAII 风格封装。
7.3 线程同步:条件变量(Condition Variable)
互斥锁解决了「互斥」的问题,但有时候我们需要「等待某个条件成立」。比如生产者-消费者模型:消费者要等队列里有数据才能取,如果队列为空,它应该休眠,而不是空转轮询。
条件变量就是干这个的。它让线程在某个条件不满足时挂起,等条件满足时被唤醒。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int data_ready = 0;
// 生产者
void* producer(void* arg) {
// 生产数据...
pthread_mutex_lock(&mutex);
data_ready = 1;
pthread_cond_signal(&cond); // 唤醒一个等待的消费者
pthread_mutex_unlock(&mutex);
return NULL;
}
// 消费者
void* consumer(void* arg) {
pthread_mutex_lock(&mutex);
while (data_ready == 0) {
pthread_cond_wait(&cond, &mutex); // 自动释放锁并等待
}
// 消费数据...
pthread_mutex_unlock(&mutex);
return NULL;
}
这里有个经典陷阱:pthread_cond_wait 为什么要在 while 循环里?因为存在「虚假唤醒」——即使没有调用 pthread_cond_signal,线程也可能被唤醒。所以必须用 while 重新检查条件。我刚开始学的时候也踩过这个坑,少写了个 while,结果程序偶尔会崩溃,查了好久才发现。
7.4 多线程服务器实现
好了,理论讲完,咱们来实战。一个典型的多线程服务器长这样:
- 主线程监听 socket,accept 等待客户端连接。
- 每来一个客户端,创建一个新线程去处理。
- 子线程处理完业务后自动退出。
下面是一个简化版的实现:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#define PORT 8888
#define MAX_CLIENTS 100
void* handle_client(void* arg) {
int client_fd = *(int*)arg;
free(arg); // 释放动态分配的内存
char buffer[1024];
int n = read(client_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("收到: %s\n", buffer);
write(client_fd, "OK", 2);
}
close(client_fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(1);
}
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);
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
listen(server_fd, 10);
printf("服务器启动,监听端口 %d\n", PORT);
while (1) {
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int* client_fd = malloc(sizeof(int));
*client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &len);
if (*client_fd < 0) {
perror("accept");
free(client_fd);
continue;
}
pthread_t tid;
pthread_create(&tid, NULL, handle_client, client_fd);
pthread_detach(tid); // 分离线程,自动回收资源
}
close(server_fd);
return 0;
}
这段代码有几个设计要点:
- 我把
client_fd用malloc分配在堆上,传给子线程。为什么?因为如果直接传栈上变量的地址,主线程下次循环就可能覆盖它。 - 用了
pthread_detach,这样子线程结束后系统自动回收资源,主线程不用pthread_join一个个等。 - 注意:这个版本没有做线程数量限制。如果同时有几千个客户端连接,系统会扛不住。实际项目中我会加一个线程池,或者用
sem_wait控制并发数。
这个图把主线程和子线程的职责分得很清楚。主线程只负责 accept 和创建线程,子线程负责具体的读写和业务处理。这种模式在业界叫「one thread per connection」,简单直接,适合中小型项目。
7.5 线程安全与常见陷阱
多线程编程,坑是真的多。我列几个最常见的:
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 死锁 | 两个线程互相等待对方释放锁 | 固定锁的获取顺序,或使用 trylock |
| 数据竞争 | 多个线程同时读写同一变量 | 加互斥锁,或使用原子操作 |
| 线程泄漏 | 创建了线程但没有 join 或 detach | 统一使用 pthread_detach 或线程池 |
| 信号处理 | 多线程中信号的处理方式不同 | 指定信号处理线程,或屏蔽信号 |
嗯,说到死锁,我记得有一次写一个双缓冲区的代码,两个线程各持一把锁,然后互相等对方释放。程序跑起来直接卡死,连 gdb 都 attach 不上去。后来我强制 core dump 才看到两个线程都在 __lll_lock_wait 里。从那以后,我写多线程代码一定会画锁的依赖图,确保没有循环等待。
好了,这一章的内容就到这里。多线程并发服务器是网络编程的必修课,pthread 库虽然看起来 API 不多,但组合起来能玩出很多花样。下一章我们会深入探讨 I/O 多路复用——select、poll、epoll,那才是真正的高并发利器。