1.核心目标
让服务器能够同时处理多个客户端的连接请求
2. 方案一:多进程模型 (Process-based)
利用 fork() 系统调用,为每一个新连接的客户端创建一个独立的子进程来处理。 关键知识点
- 僵尸进程的回收 :
- 子进程结束后,如果父进程没有读取其退出状态,子进程会变成"僵尸进程",占用系统资源。
- 解决方法 :使用信号机制。注册
SIGCHLD信号处理函数,在函数中调用wait(NULL)自动回收子进程。
- 文件描述符的回收与继承 :
fork()之后,子进程会复制 父进程的所有文件描述符(包括监听套接字listfd和通信套接字conn)。- 原则 :
- 父进程 :只负责监听 (
listfd),不需要通信 (conn)。所以父进程收到conn后应立刻close(conn)。 - 子进程 :只负责通信 (
conn),不需要监听 (listfd)。所以子进程进入循环前应close(listfd)。
- 父进程 :只负责监听 (
- 后果:如果不关闭不需要的描述符,会导致文件描述符泄露,且可能导致端口无法释放。
💻 代码逻辑梳理
A. 信号处理 (防止僵尸进程)
bash
// 定义信号处理函数
void handle(int num)
{
// wait(NULL) 会阻塞等待任意一个子进程结束,并回收其资源
// 放在信号处理函数中,当子进程结束时,内核发送 SIGCHLD 信号触发此函数
wait(NULL);
}
// 在 main 函数中注册
signal(SIGCHLD, handle);
B. 主流程 (Main Loop)
bash
int main()
{
// 1. 创建监听套接字 (socket, bind, listen) ... [省略]
while (1)
{
// 2. 接受连接 (阻塞)
int conn = accept(listfd, ...);
if (conn == -1) { perror("accept"); continue; }
// 3. 【核心】创建子进程
pid_t pid = fork();
if (pid > 0)
{
// --- 父进程区域 ---
// 父进程不需要和这个特定的客户端通信,关闭 conn
close(conn);
// 父进程继续循环,去 accept 下一个客户
// 注意:这里不需要 wait(NULL),因为已经用信号处理了
}
else if (pid == 0)
{
// --- 子进程区域 ---
// 子进程不需要监听新连接,关闭 listfd (非常重要!)
close(listfd);
// 4. 业务处理循环 (Echo Server 示例)
while (1)
{
char buf[512] = {0};
int rd_ret = recv(conn, buf, sizeof(buf), 0);
if (rd_ret <= 0)
{
printf("cli offline\n");
exit(0); // 子进程处理完毕或出错,直接退出
}
// 处理数据 (例如加上时间戳返回)
time_t tm; time(&tm);
sprintf(buf, "%s %s", buf, ctime(&tm));
send(conn, buf, strlen(buf), 0);
}
}
else
{
// fork 失败
perror("fork");
close(conn);
}
}
close(listfd);
return 0;
}
3. 方案二:多线程模型 (Thread-based)
利用 pthread_create,为每一个新连接的客户端创建一个独立的线程来处理。相比进程,线程更轻量级,切换开销小,共享内存方便。
🔑 关键知识点
- 线程属性的设置 (分离属性) :
- 默认情况下,线程结束后需要其他线程调用
pthread_join来回收资源,否则会变成"僵尸线程"。 - 解决方法 :设置线程属性为
PTHREAD_CREATE_DETACHED(分离状态)。这样线程结束后,系统会自动回收资源,无需join。
- 默认情况下,线程结束后需要其他线程调用
- 参数传递的安全性 :
pthread_create的第 4 个参数void *arg传递给线程函数。- 陷阱 :如果在主线程中直接传
&conn(局部变量的地址),由于主线程循环很快,conn的值可能在子线程还没用到时就被下一次accept修改了。 - 解决 :
- 方法 A:
malloc一块内存存conn,传给线程,线程用完free。 - 方法 B (代码中使用):使用信号量 (
sem_t) 进行同步,确保主线程在子线程保存好conn之前,不进行下一次循环修改conn。
- 方法 A:
- 互斥与同步 (信号量) :
- 代码中使用
sem_wait和sem_post来保证conn变量在多线程间传递时的安全性。
- 代码中使用
💻 代码逻辑梳理
A. 线程函数 (Worker Thread)
bash
// 全局信号量,用于同步 conn 的传递
sem_t sem_arg;
void *th(void *arg)
{
// 1. 获取传入的 conn 指针,并解引用拿到值
// 注意:这里强转为 int* 是因为传入的是 &conn
int conn = *(int*)arg;
// 2. 通知主线程:我已经拿到 conn 了,你可以继续循环了
sem_post(&sem_arg);
// 3. 设置为分离线程 (可选,也可以在 create 时设置属性)
pthread_detach(pthread_self());
// 4. 业务处理循环
while (1)
{
char buf[512] = {0};
int rd_ret = recv(conn, buf, sizeof(buf), 0);
if (rd_ret <= 0)
{
printf("cli offline\n");
close(conn);
break; // 跳出循环,线程函数结束,线程自动销毁
}
// 处理数据...
time_t tm; time(&tm);
sprintf(buf, "%s %s", buf, ctime(&tm));
send(conn, buf, strlen(buf), 0);
}
return NULL;
}
B. 主流程 (Main Loop)
bash
int main()
{
// 1. 初始化信号量 (初始值为 0)
// 意味着 sem_wait 会阻塞,直到 sem_post 被调用
sem_init(&sem_arg, 0, 0);
// 2. 创建监听套接字 (socket, bind, listen) ... [省略]
while (1)
{
int conn = accept(listfd, ...);
if (conn == -1) { perror("accept"); continue; }
pthread_t tid;
// 3. 【核心】创建线程
// 第 4 个参数传入 &conn (conn 变量的地址)
pthread_create(&tid, NULL, th, &conn);
// 4. 【同步】等待线程拿走 conn
// 如果线程还没执行到 sem_post,这里主线程会阻塞
// 防止主线程立刻进入下一次循环,修改了 conn 的值,导致线程拿到错误的 fd
sem_wait(&sem_arg);
// 此时线程已经安全保存了 conn 的值,主线程可以继续 accept 下一个
}
sem_destroy(&sem_arg);
close(listfd);
return 0;
}
4. 两种模型对比总结
| 特性 | 多进程模型 (Process) | 多线程模型 (Thread) |
|---|---|---|
| 资源开销 | 大。每个进程有独立的内存空间、页表等。 | 小。线程共享进程内存,只有栈和寄存器独立。 |
| 切换速度 | 慢。涉及上下文切换,TLB 刷新等。 | 快。只需切换栈和寄存器。 |
| 数据共享 | 难。需要使用 IPC (管道、共享内存、消息队列)。 | 易。直接读写全局变量即可 (但需加锁)。 |
| 稳定性 | 高。一个进程崩溃不影响其他进程。 | 低。一个线程崩溃 (如段错误) 可能导致整个进程挂掉。 |
| 适用场景 | 对稳定性要求极高,或需要利用多核 CPU 隔离任务。 | 高并发、IO 密集型任务 (如 Web 服务器、聊天室)。 |
| 代码关键点 | fork(), wait(), SIGCHLD, 关闭多余 fd |
pthread_create, sem_t, 线程分离, 参数传递安全 |
常见坑点
- 多进程中 :为什么子进程要
close(listfd)?- 答:如果不关闭,所有子进程都持有一份监听套接字的副本。当主进程想关闭服务器时,端口可能因为还有子进程占用而无法释放。此外,这也浪费了文件描述符资源。
- 多线程中 :为什么不能直接传
conn而要传&conn还要加信号量?- 答:
pthread_create是异步的。如果直接传conn的值(通过 malloc),没问题。但如果传主线程栈上的&conn,主线程循环极快,可能在子线程还没来得及读取*arg时,conn的值就已经变成下一个客户的 fd 了。加信号量是为了强制主线程"等一等",确保数据同步。
- 答:
- 僵尸进程/线程 :
- 进程靠
signal(SIGCHLD, handler)+wait()解决。 - 线程靠
pthread_detach()解决。
- 进程靠