一、TCP并发服务器
1.1 为什么需要并发?
单线程TCP服务器的流程是:
text
accept() → 接收一个客户端 → recv()/send() 处理 → 客户端断开 → 再 accept() 下一个
如果某个客户端不发送数据,recv 会一直阻塞,其他客户端只能干等着。实际服务器不可能这样------必须同时服务多个客户端。
1.2 多线程并发模型
思路 :主线程负责 accept 接受连接,每来一个客户端就创建一个新线程,由子线程负责处理该客户端的收发数据。
c
// 主线程
while (1) {
confd = accept(sockfd, NULL, NULL);
pthread_create(&tid, NULL, client_handler, (void *)&confd);
pthread_detach(tid); // 分离线程,自动回收
}
// 子线程
void *client_handler(void *arg)
{
int confd = *(int *)arg;
char buf[128];
while (1) {
ssize_t n = recv(confd, buf, sizeof(buf), 0);
if (n <= 0) break; // 客户端断开
send(confd, buf, n, 0);
}
close(confd);
return NULL;
}
优点 :每个客户端独立处理,互不影响
缺点:每个客户端创建一个线程,并发量大时(成百上千)系统资源消耗大
二、IO模型
2.1 阻塞IO
特点:数据没准备好时,函数一直阻塞等待,不占用CPU。
c
recv(confd, buf, sizeof(buf), 0); // 没数据时阻塞
优点 :效率高(无数据时不占CPU)
缺点:一个阻塞的IO操作会卡住整个线程
2.2 非阻塞IO
特点:数据没准备好时立即返回,不阻塞。需要不断轮询检测是否有数据。
c
// 设置文件描述符为非阻塞
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// 非阻塞读取
ssize_t n = recv(fd, buf, sizeof(buf), 0); // 没数据时立即返回-1,errno=EAGAIN
优点 :不阻塞线程,可以同时处理多个任务
缺点:轮询会占用CPU
2.3 信号驱动IO(异步IO)
特点 :内核监测文件描述符,有事件发生时发送 SIGIO 信号通知进程。
c
// 设置信号处理函数
signal(SIGIO, handler);
// 设置异步IO
fcntl(fd, F_SETOWN, getpid());
fcntl(fd, F_SETFL, O_ASYNC);
2.4 多路复用IO
特点:用一个线程监听多个文件描述符,哪个有事件就处理哪个。
三、select多路复用
3.1 基本概念
select 监听一个文件描述符集合,当集合中至少有一个描述符产生事件时返回。
3.2 相关函数
c
void FD_SET(int fd, fd_set *set); // 将fd加入集合
void FD_CLR(int fd, fd_set *set); // 将fd从集合中移除
int FD_ISSET(int fd, fd_set *set); // 判断fd是否仍在集合中
void FD_ZERO(fd_set *set); // 清空集合
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数:
-
nfds:最大文件描述符 + 1 -
readfds:读事件集合(数据可读) -
writefds:写事件集合(可写) -
exceptfds:异常事件集合 -
timeout:超时时间(NULL为阻塞,0为立即返回)
3.3 select示例
c
fd_set readfds;
int maxfd = sockfd;
while (1) {
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds); // 监听监听套接字
// 同时监听所有已连接的客户端...
int ret = select(maxfd + 1, &readfds, NULL, NULL, NULL);
if (ret <= 0) continue;
// 有新连接
if (FD_ISSET(sockfd, &readfds)) {
confd = accept(sockfd, NULL, NULL);
// 将confd加入监听集合
}
// 有客户端发数据
for (i = 0; i < client_count; i++) {
if (FD_ISSET(client_fds[i], &readfds)) {
recv(client_fds[i], buf, sizeof(buf), 0);
// 处理数据...
}
}
}
四、epoll多路复用
4.1 epoll vs select
| select | epoll | |
|---|---|---|
| 监听数量 | 受限制(通常1024) | 无限制 |
| 效率 | O(n)遍历 | O(1) 直接返回活跃事件 |
| 内存拷贝 | 每次拷贝 | 只拷贝一次 |
| 触发模式 | 水平触发 | 水平触发 / 边沿触发 |
4.2 epoll函数
epoll_create :创建事件表
c
int epoll_create(int size);
epoll_ctl : 操作事件表
c
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
| op | 含义 |
|---|---|
EPOLL_CTL_ADD |
添加事件 |
EPOLL_CTL_MOD |
修改事件 |
EPOLL_CTL_DEL |
删除事件 |
c
struct epoll_event {
uint32_t events; // EPOLLIN / EPOLLOUT / EPOLLET
epoll_data_t data; // 用户数据(通常放fd)
};
epoll_wait : 等待事件
c
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
4.3 epoll示例
c
#include <sys/epoll.h>
// 1. 创建epoll实例
int epfd = epoll_create(1);
// 2. 添加监听套接字到事件表
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
struct epoll_event events[1024];
while (1) {
// 3. 等待事件发生
int nfds = epoll_wait(epfd, events, 1024, -1);
if (nfds <= 0) continue;
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == sockfd) {
// 有新连接
int confd = accept(sockfd, NULL, NULL);
// 将confd加入事件表
ev.events = EPOLLIN;
ev.data.fd = confd;
epoll_ctl(epfd, EPOLL_CTL_ADD, confd, &ev);
} else {
// 有数据可读
char buf[128];
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) {
// 客户端断开
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
} else {
// 处理数据...
send(fd, buf, n, 0);
}
}
}
}
4.4 水平触发 vs 边沿触发
| 水平触发(默认) | 边沿触发(EPOLLET) | |
|---|---|---|
| 触发时机 | 数据可读就触发 | 数据状态变化时触发一次 |
| 处理方式 | 可分批处理 | 必须一次性读完 |
| 难度 | 简单 | 需要非阻塞 + 循环读 |
五、今日总结
今天学了TCP并发服务器 和IO模型,核心要点如下:
TCP并发服务器:
-
多线程模型:主线程
accept,子线程处理每个客户端 -
并发量大时可用线程池管理
IO模型:
-
阻塞IO:效率高,但单线程无法处理多路IO
-
非阻塞IO:不阻塞但轮询浪费CPU
-
信号驱动IO:内核信号通知
-
多路复用IO:
select/epoll
select vs epoll:
-
select简单但效率低(O(n)),有数量限制 -
epoll高效(O(1)),无数量限制,支持边沿触发
今日感悟:
多线程模型的好处是每个客户端互不干扰,一个客户端卡住不会影响其他。但线程创建和切换是有开销的,如果同时有1000个客户端,系统会扛不住,因此会使用模型来解决这个问题。
select 的核心思想是"把所有要等的文件描述符放到一个篮子里,谁有动静就处理谁"。这样单线程也能处理多个客户端,避免了创建大量线程的开销。但 select 有它的限制,文件描述符数量上限(通常是1024),而且每次都要遍历整个集合来找哪些有事件。
epoll 是 select 的升级版,核心优化是"不遍历所有,只通知有动静的"。如果监听10000个连接,select 每次要检查10000个,epoll 只返回那少数几个有事件的,效率差距非常大。这也是为什么Nginx这类高并发服务器都用 epoll。