2.1网络编程
2.2网络原理
2.3协程库
2.4dpdk
2.5高性能异步io机制
网络 I/O(Input/Output) 是指计算机通过网络与其他设备进行数据交换的过程。在编程中,网络 I/O 主要涉及:
socket()
、bind()
、listen()
、accept()
等系统调用创建 TCP/UDP 连接。send()
、recv()
等函数发送和接收数据。网络 I/O 的特点是耗时较长(相比 CPU 操作),因为数据需要在网络中传输,可能受到带宽、延迟等因素影响。
线程资源开销大:频繁创建 / 销毁线程会消耗大量 CPU 时间
可扩展性差,线程数量受限:操作系统对线程数量有限制(例如 Linux 默认最大线程数约为 32,000),无法支持海量连接(如 C10K 问题),在高并发(如 10 万 + 连接)下,线程开销会成为瓶颈
某个线程崩溃可能导致整个进程退出,影响其他连接。
因此,「一请求一线程」模型存在严重的性能瓶颈,仅适合连接数少、I/O 耗时短的场景,性能不适合高并发场景,改进方式是:
select()
、poll()
、epoll()
(Linux)或kqueue()
(BSD/macOS)等机制,让单个线程处理多个连接);aio_read()
、aio_write()
等接口,让内核完成 I/O 后通知应用程序);在 Unix 及类 Unix 系统(如 Linux、macOS 等)中,“一切皆文件” 是一个重要的概念,这意味着包括网络套接字、设备、管道等在内的多种资源都可以像普通文件一样进行操作,而文件描述符(fd)就是用于标识这些资源的一个非负整数。
文件描述符是一个非负整数,它是操作系统内核为了标识进程打开的文件或其他资源而分配的一个索引值。例如,当你打开一个普通文件时,open
函数会返回一个文件描述符,后续对该文件的读写操作(如 read
、write
等)都需要使用这个文件描述符作为参数。
在网络编程中,使用 socket
函数创建一个套接字时,操作系统会在内核中为这个套接字分配相应的数据结构,并返回一个文件描述符。这个文件描述符可以用于后续的操作,如 connect
(客户端连接服务器)、send
(发送数据)、recv
(接收数据)等,就如同对普通文件进行读写操作一样。
综上所述,在 Unix 及类 Unix 系统中,当创建 TCP 连接时,操作系统通过分配文件描述符来标识这个连接,进程通过这个文件描述符来对 TCP 连接进行各种操作,从而实现网络通信。可以使用 ls /dev/fd
查看已经使用的文件描述符
ulimit
)每个进程默认最多可打开 1024 个 FD,可通过以下方式临时修改:
# 查看当前限制
ulimit -n
# 临时提高限制(仅对当前 shell 及子进程有效)
ulimit -n 4096
# 永久修改:编辑 /etc/security/limits.conf,添加以下内容
your_username hard nofile 65535
your_username soft nofile 65535
fs.file-max
)系统全局最大 FD 数量由 fs.file-max
控制:
# 查看当前值
cat /proc/sys/fs/file-max
# 临时修改(重启失效)
sysctl -w fs.file-max=1000000
# 永久修改:编辑 /etc/sysctl.conf,添加或修改
fs.file-max = 1000000
# 使配置生效
sysctl -p
TIME_WAIT
参数(可选)网络套接字(socket)的 TIME_WAIT
状态是 TCP 协议层面的延迟关闭机制,默认持续时间是 2 倍最大段生存时间(MSL),通常为 60 秒,但这仅影响套接字资源,不影响 FD 本身的回收。
若需减少网络套接字占用的资源,可调整 TIME_WAIT
相关参数:
# 启用 TCP 快速回收(可能影响网络稳定性)
sysctl -w net.ipv4.tcp_tw_recycle=1
# 缩短 TIME_WAIT 超时时间(默认 60 秒)
sysctl -w net.ipv4.tcp_fin_timeout=30
# 允许重用处于 TIME_WAIT 的套接字
sysctl -w net.ipv4.tcp_tw_reuse=1
多路复用(Multiplexing)是一种让单个进程同时监视多个文件描述符(如套接字、管道等)的技术,当其中任何一个或多个文件描述符变为可读或可写状态时,进程能够及时处理。
select
函数概念:select
是最早出现的 I/O 多路复用函数,它允许进程监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知进程进行相应的读写操作。
函数原型
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds
:需要监视的最大文件描述符值加 1。readfds
、writefds
、exceptfds
:分别是被监视的读、写和异常处理的文件描述符集合。timeout
:超时时间,控制select
函数的阻塞行为。工作流程:
select
函数前,需要先将要监视的文件描述符添加到对应的集合(readfds
、writefds
等)中。select
函数后,进程会被阻塞,直到有文件描述符就绪或超时。select
返回后,需要遍历所有可能的文件描述符,检查哪个文件描述符在就绪集合中,然后进行相应的处理。缺点:
select
时都需要将文件描述符集合从用户空间拷贝到内核空间,开销较大。select
返回后,需要遍历所有文件描述符来找到就绪的那些,效率较低。poll
函数概念:poll
是为了克服select
的一些缺点而设计的,它和select
类似,也是用于监视多个文件描述符的状态变化。
函数原型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:一个struct pollfd
类型的数组,每个元素包含文件描述符、要监视的事件和发生的事件。nfds
:数组中元素的个数。timeout
:超时时间(毫秒)。工作流程:
struct pollfd
数组,每个元素指定要监视的文件描述符和事件。poll
函数,进程会被阻塞直到有事件发生或超时。poll
返回后,遍历pollfd
数组,检查每个元素的revents
字段,确定哪些事件发生了,然后进行相应处理。优点:
pollfd
结构而不是位掩码来表示文件描述符集合,更加直观和灵活。缺点:
poll
时,仍需要将pollfd
数组从用户空间拷贝到内核空间。epoll
函数事件驱动机制:epoll
使用事件通知机制,当文件描述符就绪时,主动通知应用程序,无需遍历所有描述符。
红黑树 + 链表:
三种系统调用
int epoll_create(int size); // 创建 epoll 实例(size 参数已废弃,填大于 0 的值即可)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待事件发生
EPOLL_CTL_ADD
EPOLL_CTL_DEL
EPOLL_CTL_MOD
IO管理的三种操作epoll_event
就是处理数据的小车maxevents
是小车有多大工作模式
水平触发(LT,Level Triggered,默认模式):
epoll_wait
就会一直返回该事件。epoll_wait
仍会触发。边缘触发(ET,Edge Triggered):
优点:
epoll
无最大连接数限制(仅受系统文件描述符上限约束)。poll
为 O (n)),适合高并发场景。特性 | select | poll | epoll |
---|---|---|---|
文件描述符上限 | 受 FD_SETSIZE 限制(通常为 1024) |
无硬性限制,取决于系统资源 | 无上限,仅受系统资源限制 |
数据结构 | 位掩码(bitmap) | 数组(struct pollfd ) |
红黑树(存储监视对象) + 链表(就绪列表) |
事件通知机制 | 轮询(遍历所有描述符) | 轮询(遍历所有描述符) | 事件驱动(回调函数 + 就绪链表) |
内存拷贝 | 每次调用需从用户空间到内核空间拷贝 | 每次调用需从用户空间到内核空间拷贝 | 仅在注册事件时拷贝一次(epoll_ctl ) |
时间复杂度 | O(n) | O(n) | O (1)(仅遍历就绪链表) |
工作模式 | 仅支持水平触发(LT) | 仅支持水平触发(LT) | 支持水平触发(LT)和边缘触发(ET) |
适用场景 | 小规模连接(描述符少)Windows系统 | 小规模连接(描述符少)(连接数中等) | 大规模高并发(如百万级连接)(如 Nginx、Redis 等高性能服务器) |
select/poll
时,需将全部描述符集合从用户空间拷贝到内核空间。select
受 FD_SETSIZE
限制,难以处理大量连接。mmap
实现用户空间和内核空间的内存共享,避免频繁拷贝。epoll_ctl
)时拷贝一次数据,后续 epoll_wait
无需重复拷贝。epoll
通过内核级的事件表和回调机制,实现了从 “被动轮询” 到 “主动通知” 的质变,这使其成为现代高性能网络服务器的核心技术之一LT 和 ET 的核心区别
水平触发(LT)
触发条件:只要文件描述符(FD)处于就绪状态(如可读缓冲区有数据),就会持续触发事件。
特性:
边缘触发(ET)
触发条件:仅在 FD 状态变化时触发一次(如数据从无到有)。
特性:
select/poll仅支持 LT 模式;
epoll同时支持 LT 和 ET:默认是 LT 模式,通过 EPOLLET
标志可启用 ET 模式。
为什么 ET 模式要求非阻塞 I/O?
阻塞 I/O 与 ET 的矛盾:
read
调用中。正确做法:
fcntl(fd, F_SETFL, O_NONBLOCK)
)。
EAGAIN
(表示缓冲区已空 / 满)。场景 | LT 模式 | ET 模式 |
---|---|---|
编程复杂度 | 低(无需循环处理) | 高(必须循环处理 + 非阻塞 I/O) |
性能 | 中等(可能有冗余通知) | 高(减少系统调用次数) |
适用场景 | 简单应用(如小规模连接) | 高性能服务器(如 Nginx、Redis) |
数据处理要求 | 可部分处理数据 | 必须一次性处理完所有数据 |
#include
// #include
#include
#include
#include
#include
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
/*
struct sockaddr_in {
short sin_family; // 地址族,通常为 AF_INET
unsigned short sin_port; // 端口号
struct in_addr sin_addr; // IPv4 地址
char sin_zero[8]; // 填充字节,使 sockaddr_in 和 sockaddr 大小相同
};
struct in_addr {
unsigned long s_addr; // IPv4 地址,以网络字节序存储
};
*///sockaddr_in的结构体
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
// htonl是 “Host to Network Long” 的缩写,
// 其作用是将一个 32 位的无符号整数从主机字节序转换为网络字节序。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// htons是 “Host to Network Short” 的缩写,
// 用于将一个 16 位的无符号整数从主机字节序转换为网络字节序
servaddr.sin_port = htons(2000);
// 绑定文件描述符和地址,并检错
// 注意这里第三个参数是结构体的长度而不是指针的长度
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
// 开始监听
listen(sockfd, 10);
printf("listen finished\n");
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished\n");
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
// 把程序阻塞在这里不要往下走
getchar();
printf("exit\n");
return 0;
}
TIPS:
如果不知道某个函数的头文件在哪里,可以用
man
来查找该函数的手册,比如man strerror
发现该函数在string.h
中如果需要查看某个网络端口的服务有没有启动,可以用
netstat -anop | grep 2000(端口号)
来查询该服务有没有启动
- 3306 mysql端口
- 6709 redis端口
cv@ubuntu:~$ netstat -anop | grep 2000
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:2000 0.0.0.0:* LISTEN - off (0.00/0/0)
tcp 80 0 192.168.21.129:2000 192.168.21.1:57037 ESTABLISHED - off (0.00/0/0)
初代代码实现了一个简单的 TCP 服务器,它创建套接字、绑定到本地端口 2000 并监听连接,接受一个客户端连接后接收其发送的数据并原样返回,最后等待用户输入才退出程序。需要解决的问题:
现象观察:如果此时再启动一个服务器段的,network程序连接网关,会发现端口占用:
bind failed: Address already in use
如果此时再用网络助手连接2000端口,会出现以下现象,端口没有被占用,连接成功:
cv@ubuntu:~$ netstat -anop | grep 2000
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 0.0.0.0:2000 0.0.0.0:* LISTEN - off (0.00/0/0)
tcp 80 0 192.168.21.129:2000 192.168.21.1:57037 ESTABLISHED - off (0.00/0/0)
原因是一个端口在同一时刻只能被一个进程绑定,当服务器在某个端口上进行监听时,它可以同时接受多个客户端的连接。
每当有一个客户端请求连接到服务器的指定端口时,服务器就会创建一个新的连接套接字(在代码中通常用新的文件描述符表示)来与该客户端进行通信,而服务器监听的端口仍然保持监听状态,继续接受其他客户端的连接请求。
程序优化:端口被绑定以后,不能再次被绑定。(如何在一个端口建立多个连接)
因此建立一个while循环,建立一次连接,就创建一个新的fd。
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed\n");
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
程序优化:进入listen可以被连接,需要马上收发一次,然后再建立新的连接
可以每次建立连接时新开一个线程,专门处理这个线程内的连接
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished\n");
pthread_t pthread_id;
pthread_create(&pthread_id, NULL, client_thread, &clientfd);
}
程序优化:发送消息后只能收发一次
recv处加上一个while循环
void *client_thread(void *arg) {
int clientfd = *(int*)arg;
while (1) {
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", clientfd);
close(clientfd);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
程序优化:客户端断开后,程序进入死循环
加入处理断开 recv()返回0
的逻辑
void *client_thread(void *arg) {
int clientfd = *(int*)arg;
while (1) {
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
// 加入处理断开 `recv()返回0` 的逻辑
if (count == 0) { // disconnect
printf("client disconnect: %d\n", clientfd);
close(clientfd);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
现象观察:文件描述符fd依次递增
cv@ubuntu:~/share/0voice/2.High_Performance_Network/2.1.1Network_Io$ sudo ./network
listen finished: 3
accept
accept finished: 4
accept
accept finished: 5
accept
accept finished: 6
accept
RECV: Welcome to NetAssist
SEND: 20
ls /dev/fd
目录下的文件是文件描述符的符号链接,输出为 0 1 2
,分别代表标准输入、标准输出和标准错误输出,它们是系统默认的文件描述符, 通过 ls /dec/stdin -l
可以查看他们的信息open files
的数量,用 ulimit -a
查看核心逻辑: 通过 select
监听套接字 sockfd
的可读事件,当有数据可读时(如客户端连接或数据到达),select
返回并通知程序处理。
// 定义主要\工作文件描述符集合
fd_set rfds, rset;
// 清空文件描述符集合 rfds
FD_ZERO(&rfds);
// 将套接字 sockfd 添加到 rfds 集合中,表示需要监听该套接字的可读事件。
FD_SET(sockfd, &rfds);
// select 的第一个参数需要传入最大文件描述符值 + 1。
// 由于当前集合中只有 sockfd,因此 maxfd 初始化为 sockfd。
int maxfd = sockfd;
while (1) {
// 每次循环开始时,将主集合 rfds 复制到工作集合 rset,因为 select 会修改工作集合。
rset = rfds;
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
// accept部分,检查sockfd是否可读集合
if (FD_ISSET(sockfd, &rset)) {
// 接受新的客户端连接
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
// 将新客户端的文件描述符添加到主监听集合中
FD_SET(clientfd, &rfds);
// 更新maxfd为所有监听描述符中的最大值
if (clientfd > maxfd) maxfd = clientfd;
}
// recv部分
int i = 0;
for (i = sockfd + 1; i <= maxfd; i++) {
if (FD_ISSET(i, &rset)) {
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
// 这里应该连接fd的值为i
if (count == 0) { // disconnect
printf("client disconnect: %d\n", i);
close(i);
// 断开时在集合中应该把客户端的fd清空
FD_CLR(i, &rfds);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
代码解释:int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
select
函数监听文件描述符集合 rset
中的可读事件。maxfd+1
:指定监听的文件描述符范围(从 0 到 maxfd
)。&rset
:监听可读事件的文件描述符集合。NULL
:不监听可写事件。NULL
:不监听异常事件。NULL
:阻塞模式,直到有文件描述符就绪。nready
:就绪的文件描述符总数。select
返回后,需要遍历文件描述符集合检查哪些就绪FD_ZERO
FD_ZERO(fd_set *set)
。fd_set
类型的集合 set
初始化为空集,即把集合中表示各个文件描述符的位都清零 ,确保集合中不包含任何文件描述符。FD_SET
FD_SET(int fd, fd_set *set)
。fd
添加到集合 set
中 ,也就是将集合中对应 fd
的位设置为 1 ,表示该文件描述符在集合内,后续可对其进行相关状态检测。FD_CLR
FD_CLR(int fd, fd_set *set)
。set
中移除指定的文件描述符 fd
,即将集合中对应 fd
的位设置为 0 ,表示该文件描述符不在集合内了。FD_ISSET
FD_ISSET(int fd, fd_set *set)
。fd
是否在集合 set
中。如果 fd
在集合 set
中,返回值为非零(表示真) ;如果不在集合中,返回值为 0(表示假) 。常配合 select
等函数使用,在 select
返回后,判断哪些文件描述符满足了相应条件。fd_set
是一种用于在多路复用 I/O 操作中存储文件描述符集合的数据结构 ,常与 select
函数配合使用。
fd_set
是一个 bit 位集合,它采用类似位图(Bitmap)的方式,其中每一位对应一个文件描述符。若某一位被置为 1 ,代表对应的文件描述符在集合内;若为 0 ,则表示不在集合内。fd_set
就有 1024 个位与之对应 ,某位为 1 代表对应文件描述符在集合内,为 0 则不在。缺点:
select
时都需要将文件描述符集合 fd_set
从用户空间拷贝到内核空间,开销较大。select
返回后,需要遍历所有文件描述符 fd_set
来找到就绪的那些,效率较低。核心逻辑: 使用 poll
函数实现了一个简单的 TCP 服务器,它持续监听客户端连接和数据收发,当有新连接或数据到来时进行相应处理。
// 代码初始化了一个包含 1024 个 pollfd 结构的数组,并设置监听套接字 sockfd 关注可读事件(即新连接到来)。
struct pollfd fds[1024] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
int maxfd = sockfd;
while (1) {
// poll 返回发生事件的文件描述符总数,存储在 nready 中
int nready = poll(fds. maxfd+1, -1);
// 当 sockfd 上有可读事件(POLLIN)发生时,表示有新的客户端连接
if (fds[sockfd].revents & POLLIN) {
// accept 函数接受连接并返回新的客户端套接字 clientfd
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
// 更新 maxfd 为当前最大的文件描述符值
if (clientfd > maxfd) maxfd = clientfd;
}
// 遍历所有可能有数据可读的客户端套接字(从 sockfd+1 到 maxfd)
for (i = sockfd+1; i <= maxfd; i++) {
// 当某个客户端套接字有可读事件时
if (fds[i].revents & POLLIN) {
// 使用 recv 接收数据
int count = recv(i, buffer, 1024, 0);
// 如果 recv 返回 0,表示客户端关闭连接,
// 此时关闭套接字并从 pollfd 数组中移除(将 fd 设为 - 1,events 设为 0)
if (count == 0) {
close(i);
fds[i].fd = -1;
fds[i].events = 0;
} else {
send(i, buffer, count, 0);
}
}
}
struct pollfd是poll函数使用的数据结构,包含三个成员:
fd
:文件描述符events
:要监听的事件(如 POLLIN
表示可读事件)revents
:实际发生的事件(由 poll
函数填充)poll(fds, maxfd+1, -1)
fds
是要监听的文件描述符数组maxfd+1
表示数组中有效元素的数量(从 0 到 maxfd
)-1
表示无限等待,直到有事件发生poll
返回发生事件的文件描述符总数,存储在 nready
中优点:
pollfd
结构而不是位掩码来表示文件描述符集合,更加直观和灵活。缺点:
poll
时,仍需要将pollfd
数组从用户空间拷贝到内核空间。核心逻辑: epoll
实现了一个高效的 TCP 服务器,通过事件驱动方式同时处理多个客户端连接的读写操作,避免了轮询开销。
// 创建一个 epoll 实例,返回文件描述符 epfd。
// 传入参数必须为正数(历史上表示初始事件表大小)
int epfd = epoll_create(1);
// 注册监听事件
struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件
ev.data.fd = sockfd; // 绑定监听套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
while (1) {
// 等待事件触发
struct epoll_event events[1024] = {0};
int nready = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nready; i++) {
int connfd = events[i].data.fd;
// 当 sockfd 就绪时,表示有新连接
if (connfd == sockfd) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}
// 当客户端套接字就绪时,读取数据
else if (events[i].events & EPOLLIN) {
char buffer[1024] = {0};
int count = recv(connfd, buffer, 1024, 0);
if (count == 0) { // 客户端关闭连接
close(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
} else { // 正常数据接收
send(connfd, buffer, count, 0);
}
// parser
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
int epfd = epoll_create(1)
创建一个 epoll 实例
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev)
:注册监听事件参数:
epfd
:epoll_create
返回的句柄。EPOLL_CTL_ADD
:添加事件。sockfd
:要监听的套接字。&ev
:事件结构体,包含监听类型(EPOLLIN
)和自定义数据(data.fd
)。epoll_wait(epfd, events, 1024, -1)
:等待事件触发参数
epfd
:epoll
实例句柄。events
:输出参数,存储就绪事件的数组。1024
:数组最大容量。-1
:阻塞等待(直到有事件发生)。关键点总结
高效事件通知:epoll
使用事件表(而非轮询),仅返回就绪的文件描述符,适合处理大量连接。
水平触发模式(LT):默认模式下,只要数据未读完,EPOLLIN
会持续触发。
数据结构:
struct epoll_event
包含 events
(事件类型)和 data
(自定义数据,通常存 fd
)。对比 poll
:
epoll
无最大连接数限制(仅受系统文件描述符上限约束)。poll
为 O (n)),适合高并发场景。#include
// #include
#include
#include
#include
#include
#include
#include
#include
#include
#include
void *client_thread(void *arg) {
int clientfd = *(int*)arg;
while (1) {
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
if (count == 0) { // disconnect
printf("client disconnect: %d\n", clientfd);
close(clientfd);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
/*
struct sockaddr_in {
short sin_family; // 地址族,通常为 AF_INET
unsigned short sin_port; // 端口号
struct in_addr sin_addr; // IPv4 地址
char sin_zero[8]; // 填充字节,使 sockaddr_in 和 sockaddr 大小相同
};
struct in_addr {
unsigned long s_addr; // IPv4 地址,以网络字节序存储
};
*///sockaddr_in的结构体
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
// htonl是 “Host to Network Long” 的缩写,
// 其作用是将一个 32 位的无符号整数从主机字节序转换为网络字节序。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// htons是 “Host to Network Short” 的缩写,
// 用于将一个 16 位的无符号整数从主机字节序转换为网络字节序
servaddr.sin_port = htons(2000);
// 绑定文件描述符和地址,并检错
// 注意这里第三个参数是结构体的长度而不是指针的长度
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
}
// 开始监听
listen(sockfd, 10);
printf("listen finished: %d\n", sockfd);
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
#if 0
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished\n");
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %s\n", count);
#elif 0
// 端口被绑定以后,不能再次被绑定。(如何在一个端口建立多个连接)
// 因此建立一个循环,进行一次收发,建立一次连接
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finshed\n");
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND: %d\n", count);
}
// 还有新的问题,和建立连接的顺序有关系,导致一些逻辑上的连接阻塞
// 可以每次建立连接时新开一个线程,专门处理这个线程内的连接
#elif 0 // 这就是一请求一线程的连接方式
while (1) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
pthread_t pthread_id;
pthread_create(&pthread_id, NULL, client_thread, &clientfd);
}
#elif 0
// 定义主要\工作文件描述符集合
fd_set rfds, rset;
// 清空文件描述符集合 rfds
FD_ZERO(&rfds);
// 将套接字 sockfd 添加到 rfds 集合中,表示需要监听该套接字的可读事件。
FD_SET(sockfd, &rfds);
// select 的第一个参数需要传入最大文件描述符值 + 1。
// 由于当前集合中只有 sockfd,因此 maxfd 初始化为 sockfd。
int maxfd = sockfd;
while (1) {
// 每次循环开始时,将主集合 rfds 复制到工作集合 rset,因为 select 会修改工作集合。
rset = rfds;
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
// accept部分,检查sockfd是否可读集合
if (FD_ISSET(sockfd, &rset)) {
// 接受新的客户端连接
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
// 将新客户端的文件描述符添加到主监听集合中
FD_SET(clientfd, &rfds);
// 更新maxfd为所有监听描述符中的最大值
if (clientfd > maxfd) maxfd = clientfd;
}
// recv部分
int i = 0;
for (i = sockfd + 1; i <= maxfd; i++) {
if (FD_ISSET(i, &rset)) {
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
// 这里应该连接fd的值为i
if (count == 0) { // disconnect
printf("client disconnect: %d\n", i);
close(i);
// 断开时在集合中应该把客户端的fd清空
FD_CLR(i, &rfds);
break;
}
// parser
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
#elif 0
// 代码初始化了一个包含 1024 个 pollfd 结构的数组,并设置监听套接字 sockfd 关注可读事件(即新连接到来)。
struct pollfd fds[1024] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
int maxfd = sockfd;
while (1) {
// poll 返回发生事件的文件描述符总数,存储在 nready 中
int nready = poll(fds, maxfd+1, -1);
// 当 sockfd 上有可读事件(POLLIN)发生时,表示有新的客户端连接
if (fds[sockfd].revents & POLLIN) {
// accept 函数接受连接并返回新的客户端套接字 clientfd
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
// 更新 maxfd 为当前最大的文件描述符值
if (clientfd > maxfd) maxfd = clientfd;
}
// 遍历所有可能有数据可读的客户端套接字(从 sockfd+1 到 maxfd)
for (int i = sockfd+1; i <= maxfd; i++) {
// 当某个客户端套接字有可读事件时
if (fds[i].revents & POLLIN) {
// 使用 recv 接收数据
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
// 如果 recv 返回 0,表示客户端关闭连接,
// 此时关闭套接字并从 pollfd 数组中移除(将 fd 设为 - 1,events 设为 0)
if (count == 0) {
close(i);
fds[i].fd = -1;
fds[i].events = 0;
} else {
send(i, buffer, count, 0);
}
// parser
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
#else
// 创建一个 epoll 实例,返回文件描述符 epfd。
// 传入参数必须为正数(历史上表示初始事件表大小)
int epfd = epoll_create(1);
// 注册监听事件
struct epoll_event ev;
ev.events = EPOLLIN; // 监听读事件
ev.data.fd = sockfd; // 绑定监听套接字
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
while (1) {
// 等待事件触发
struct epoll_event events[1024] = {0};
int nready = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nready; i++) {
int connfd = events[i].data.fd;
// 当 sockfd 就绪时,表示有新连接
if (connfd == sockfd) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}
// 当客户端套接字就绪时,读取数据
else if (events[i].events & EPOLLIN) {
char buffer[1024] = {0};
int count = recv(connfd, buffer, 1024, 0);
if (count == 0) { // 客户端关闭连接
close(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
} else { // 正常数据接收
send(connfd, buffer, count, 0);
}
// parser
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND: %d\n", count);
}
}
}
#endif
// 把程序阻塞在这里不要往下走
getchar();
printf("exit\n");
return 0;
}
https://github.com/0voice