Linux中一切操作都是在操作文件,所以fd 0, 1, 2分别被预先占用并指定为标准输入,标准输出和标准错误。这三个描述符在每个进程创建时自动打开并与对应的标准流关联
fd是一个int型的值,依次增长
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr; //服务器配置
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htons(INADDR_ANY);
servaddr.sin_port = htons(2000); //0-1023为系统默认
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr)))
{
printf("bind faild: %s\n", strerror(errno));
}
listen(sockfd, 10);
printf("listen finished: %d\n", sockfd); // listen finished: 3
struct sockaddr_in clientaddr; //客户端配置
socklen_t len = sizeof(clientaddr);
sockfd通过listen(sockfd, 10)同时监测十条线路,但从始至终只有一个sockfd,编号为3
1.一请求一线程
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) //返回0 断开
{
printf("client: disconnect: %d\n", clientfd);
close(clientfd);
break;
}
printf("RECV: %s\n", buffer);
count = send(clientfd, buffer, count, 0);
printf("SEND SUCCEEDED: %d bytes\n", count);
}
}
int main(){
// .........
while(1)
{
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
pthread_t thread_id;
pthread_create(&thread_id, NULL, client_thread, &clientfd);
}
}
“一请求一线程”是一个典型的同步模型。在这个模型中,每个客户端连接都会创建一个单独的线程,线程处理时会等待I/O操作完成,导致线程被阻塞(同步)。
工作流程:
-
每个连接一个线程:当有新的客户端连接时,服务器会创建一个新的线程来处理该客户端的请求。
-
阻塞 I/O:线程在执行 I/O 操作时(如读取数据或等待网络请求),会被阻塞,直到该操作完成。线程在阻塞期间无法做任何其他工作。
-
资源开销大:如果有成百上千的客户端连接,服务器需要创建大量的线程,而每个线程都可能因为 I/O 操作被阻塞,占用大量的系统资源(如内存和CPU)。
同步特性:
-
阻塞操作:每个线程在发起 I/O 操作(如读取、写入、连接等)时必须等待操作完成后才能继续执行其他任务。
-
线程数量与请求一一对应:每个客户端请求一个线程,所以处理并发请求的能力有限,受限于系统可以同时创建的线程数。
2.select
int main()
{
fd_set rfds, rset; // rfds用户态, rset内核态
FD_ZERO(&rfds);
FD_SET(sockfd, &rfds); // 将sockfd添加到rfds中,便于监测
int maxfd = sockfd;
while (1)
{
printf("\n");
rset = rfds;
int nready = select(maxfd + 1, &rset, NULL, NULL, NULL); // 返回可读总数
printf("nready: %d\n", nready);
if (FD_ISSET(sockfd, &rset)) // 若sockfd监测的线路可操作
{
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
FD_SET(clientfd, &rfds);
if (clientfd > maxfd) maxfd = clientfd;
}
printf("maxfd: %d\n", maxfd);
//recv
int i = 0;
for (i = sockfd + 1; i <= maxfd; i++)
{
if (FD_ISSET(i, &rset)) //监测哪个客户端有消息
{
printf("now clientfd: %d\n", i);
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) //返回0 断开
{
printf("client: disconnect: %d\n", i);
close(i);
FD_CLR(i, &rfds);
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND SUCCEEDED: %d bytes\n", count);
}
}
}
}
select()函数的具体作用与原理:
-
返回有多少个文件描述符准备就绪(可读、可写或有错误发生)。
-
select()
函数的返回值就是准备好的文件描述符(即处于可读、可写、或有异常)的总数。 -
如果返回的数字大于 0,表示有这些文件描述符可以进行接收、发送或其他操作(具体取决于你监听的事件类型)。
-
将有事件的文件描述符对应的位在
fd_set
中置位。
-
select()
函数会修改传入的fd_set
集合(例如rset
),并将有事件的文件描述符对应的位置为 1。 -
你可以通过
FD_ISSET(fd, &rset)
来检查某个文件描述符是否被置位,表示该文件描述符有可读事件。
关于 sockfd
的位在 rset
中:
fd_set
的本质是一个位图,每一个文件描述符都对应位图中的一个位。假设 sockfd
是 3
,那么它对应的就是 fd_set
位图中的第 3
位。当有数据(可读事件)发生时,select()
会把 rset
中第 3
位设置为 1
,表示 sockfd
上有数据可以读取。
示例:
假设 sockfd = 3
,clientfd1 = 4
,clientfd2 = 5
,你在 select()
中监听这些文件描述符的可读事件:
-
如果
sockfd
上有新的客户端连接,select()
会将rset
中对应sockfd
的位设置为1
。 -
如果
clientfd1
上有数据可读取,select()
会将rset
中对应clientfd1
的位设置为1
。
select()的缺点
-
文件描述符数量限制:
-
select
对文件描述符(FD)的数量有硬性限制,通常是FD_SETSIZE
(默认 1024)。这意味着select
最多只能同时监控 1024 个文件描述符。如果需要处理更多的文件描述符,必须通过修改系统配置或重新编译代码来增加这一限制,这在高并发场景中是一个瓶颈。
-
性能问题(O(n) 时间复杂度):
-
select
每次调用时,都会遍历整个文件描述符集合(即使很多文件描述符上没有事件发生),以检查哪些文件描述符有事件。这种遍历的时间复杂度是 O(n),其中n
是文件描述符的数量。在高并发场景下,处理大量文件描述符时,性能开销较大。
-
重复设置文件描述符集合:
-
每次调用
select
,都需要重新设置文件描述符集合(如FD_SET
操作),因为select
会修改传入的文件描述符集合。这意味着你必须在每次调用select
前重新初始化文件描述符集合,这增加了开销和代码的复杂度。
-
无法处理“水平触发”之外的模式:
-
select
只能使用“水平触发”(Level Triggered),即只要文件描述符有事件未处理,select
会一直返回该事件。这意味着如果处理速度较慢,可能会反复得到同一事件,浪费系统资源。
-
内存拷贝开销:
-
每次调用
select
时,文件描述符集合需要从用户空间拷贝到内核空间,处理完后再将结果从内核空间拷贝回用户空间。这种频繁的内存拷贝在文件描述符数量较多时会带来额外的性能开销。
3.poll
struct pollfd{
int fd;
short events;
short revents;
}
poll
中的 events
和 revents
的作用:
events
:
-
events
是一个输入字段,用于告诉poll
函数你希望监听的文件描述符上哪些事件。 -
它是一个位掩码(bitmask),可以同时监听多个事件。
-
常见的
events
值有:-
POLLIN
:监听文件描述符上是否有可读数据。 -
POLLOUT
:监听文件描述符上是否可以写入数据。 -
POLLERR
:监听文件描述符上是否有错误发生。 -
POLLHUP
:监听文件描述符是否挂起(比如连接关闭)。
-
例如,你可以通过设置 pollfd[i].events = POLLIN | POLLOUT;
来同时监听可读和可写事件。
revents
:
-
revents
是poll
函数的输出字段,表示文件描述符上实际发生了哪些事件。 -
当
poll
函数返回时,revents
会被设置为相应的事件标志,指示文件描述符发生了什么事情。 -
你可以通过检查
pollfd[i].revents
来查看对应的文件描述符上发生了哪些事件。
int main()
{
struct pollfd fds[1024] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
int maxfd = sockfd;
while (1)
{
int nready = poll(fds, maxfd + 1, -1);
if (fds[sockfd].revents & POLLIN) // 若sockfd可读
{
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd;
}
int i = 0;
for (i = sockfd + 1; i <= maxfd; i++)
{
if (fds[i].revents & POLLIN)
{
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) //返回0 断开
{
printf("client: disconnect: %d\n", i);
close(i);
fds[i].fd = -1;
fds[i].events = 0;
continue;
}
printf("RECV: %s\n", buffer);
count = send(i, buffer, count, 0);
printf("SEND SUCCEEDED: %d bytes\n", count);
}
}
}
}
4.epoll
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);
int i = 0;
for (i = 0; i < nready; i++)
{
int connfd = events[i].data.fd;
if (connfd = sockfd)
{
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept finished: %d\n", clientfd);
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) //返回0 断开
{
printf("client: disconnect: %d\n", connfd);
close(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, &ev);
continue;
}
printf("RECV: %s\n", buffer);
count = send(connfd, buffer, count, 0);
printf("SEND SUCCEEDED: %d bytes\n", count);
}
}
}
epfd
用法
epfd
是通过调用 epoll_create
函数创建的一个文件描述符,它用于引用一个新的 epoll 实例。在这个实例中,你可以注册和管理多个文件描述符的 I/O 事件。epfd
是管理这些文件描述符的句柄。
-
epoll_create(1)
创建了一个 epoll 实例并返回一个文件描述符epfd
。这个epfd
是后续调用epoll_ctl
、epoll_wait
等函数时用于标识 epoll 实例的句柄。 -
参数
1
是内核使用的提示值,它指定了初始的事件列表大小,早期版本的 Linux 使用它来确定 epoll 内部数据结构的大小,但现在在大多数实现中已经不再重要。数组-->链表
ev
变量
ev
是一个 struct epoll_event
结构体,用于描述要监听的文件描述符及其关联的事件类型。在代码中,它表示要对哪些事件感兴趣(比如可读、可写等),以及发生这些事件时需要返回的信息。
结构体 struct epoll_event
的定义如下:
struct epoll_event {
uint32_t events; // 事件掩码
epoll_data_t data; // 用户数据
};
-
events
字段用于指定感兴趣的事件类型(例如,EPOLLIN
表示可读事件)。 -
data.fd
用于存储与该事件关联的文件描述符,或者你可以存储任意用户定义的数据。
在代码中:
struct epoll_event ev;
ev.events = EPOLLIN; // 表示我们感兴趣的是可读事件
ev.data.fd = sockfd; // 存储与事件相关的文件描述符,sockfd
这表示程序想要监听 sockfd
上的可读事件(即 EPOLLIN
)。
epoll_ctl
的用法
epoll_ctl
是用于向 epoll 实例注册、修改或删除文件描述符的函数。它的原型如下:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd
:由epoll_create
返回的 epoll 实例文件描述符。 -
op
:操作类型,有三种常见操作:-
EPOLL_CTL_ADD
:向 epoll 实例中注册一个新的文件描述符。 -
EPOLL_CTL_MOD
:修改已经注册的文件描述符的监听事件。 -
EPOLL_CTL_DEL
:从 epoll 实例中移除一个文件描述符。
-
-
fd
:要操作的文件描述符。 -
event
:与fd
关联的事件。
epoll_ctl
中同时传希望操作的fd和&ev
在 epoll_ctl
调用中,虽然 &ev
中存储了文件描述符(比如 sockfd
),但是 epoll_ctl
函数的设计要求仍然需要在参数列表中显式传递文件描述符 sockfd
。这是因为 epoll_ctl
的不同参数有不同的功能,具体地说:
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
让我们分析每个参数的用途:
-
epfd
:是 epoll 实例的文件描述符,用来标识哪个 epoll 实例正在被操作。 -
EPOLL_CTL_ADD
:这是操作类型,表示你希望将某个文件描述符添加到 epoll 实例中。 -
sockfd
:是你要操作的目标文件描述符。在EPOLL_CTL_ADD
操作中,它是你希望 epoll 监听的文件描述符。即便你在&ev
中也存储了文件描述符,epoll_ctl
仍然需要这个参数来直接告诉它操作哪个文件描述符。对于EPOLL_CTL_MOD
和EPOLL_CTL_DEL
操作,它也是用于标识哪个文件描述符需要修改或删除。 -
&ev
:是包含具体事件和关联数据的结构体指针。这个结构体不仅包含要监听的事件(比如EPOLLIN
),还可以存储与事件相关的其他数据,如文件描述符或用户定义的数据。
为什么需要同时传递 sockfd
和 &ev
?
-
sockfd
参数:明确指出你要对哪个文件描述符进行操作。它作为epoll_ctl
的第三个参数是专门用来标识你要操作的文件描述符。 -
&ev
结构体:用于传递要监听的事件类型(例如EPOLLIN
、EPOLLOUT
等)以及事件发生时需要关联的数据。虽然你在ev.data.fd
中也存储了sockfd
,但epoll_ctl
使用的是作为第三个参数传递的sockfd
来管理 epoll 的文件描述符列表。
epoll的具体原理(借用快递员比喻)
快递员与传统模型 (select
/ poll
)
假设你是一个负责送快递的快递员(服务器),每个客户(文件描述符 fd
)随时可能有包裹要发(即有 I/O 事件)。在传统模型(select
或 poll
)中,你每天都要做以下工作:
-
你拿着客户名单,逐个打电话给所有客户,问他们“有没有包裹要发?”(遍历所有文件描述符,查看是否有事件发生)。
-
如果某些客户有包裹,你就去处理他们的请求(有事件的
fd
),然后再继续问下一个客户。 -
即便大部分客户可能没有包裹,你仍然得挨个询问,效率非常低,特别是当客户数量(文件描述符)很多的时候。
这个方法很浪费时间,因为你一直在询问“没有事件”的客户。
快递员与 epoll 模型
现在引入了一个更智能的快递员系统,叫做 epoll。这个系统的运作方式与传统方法不同:
-
登记客户信息:当客户第一次有可能发包裹时(文件描述符需要监听时),他们会主动联系你(使用
epoll_ctl
注册),告诉你:“如果我有包裹,我会通知你。”(注册感兴趣的事件)。 -
只通知有包裹的客户:现在你不再需要每天打电话问所有客户了。如果某个客户有包裹要发,他们会自动通知你(通过
epoll_wait
等待,只有事件发生时才收到通知)。只有在客户有需求时,快递员才被叫去处理,而不用浪费时间挨个询问。 -
精准高效:每次你只会收到有包裹的客户的通知,你直接去处理他们的需求(处理有事件的文件描述符),不需要遍历整个客户名单。
为什么 epoll 不需要循环遍历?
epoll 的原理就像客户自己通知快递员他们有包裹(事件发生),不需要快递员每次都去问所有客户(遍历所有文件描述符)。因为 epoll 通过底层内核机制(如 event-driven
,基于事件驱动),内核会主动告诉你哪些文件描述符有事件发生,所以它避免了每次从头到尾检查一遍所有文件描述符的低效做法。
总的来说,就是当文件描述符需要监听时,会被epoll_ctl注册,而接下来只需要epoll_wait等待这些文件描述符再次发生操作通知epoll,不需要遍历他们是否有操作
在 epoll 中,有两类不同的事件和文件描述符要处理:
-
已注册的文件描述符集合:也就是你通过
epoll_ctl
注册进 epoll 实例的所有文件描述符。 -
就绪的 I/O 事件集合:即那些已经有 I/O 事件发生、可以立即处理的文件描述符。
它们使用不同的数据结构进行存储和管理。下面来解释这两部分的数据结构:
1. 已注册的文件描述符集合
当你调用 epoll_ctl
并将文件描述符加入到 epoll 中时,epoll 需要维护一个所有注册文件描述符的集合。这个集合存储了你想要监控的文件描述符,以及这些文件描述符上感兴趣的事件类型(如 EPOLLIN
, EPOLLOUT
等)。
数据结构:已注册的文件描述符集合通常使用一个 红黑树(Red-Black Tree)来存储。
-
红黑树 是一种自平衡二叉搜索树,它的查找、插入、删除操作的时间复杂度为 O(log n),这使得在 epoll 中添加、修改、删除文件描述符时效率很高。
-
当你调用
epoll_ctl
函数时,epoll 会将新的文件描述符插入到红黑树中,或者更新树中的现有条目。这使得 epoll 能够快速管理和查找需要监控的文件描述符。
为什么使用红黑树:
-
红黑树能够高效地进行增删改查操作,保证操作的时间复杂度为 O(log n)。
-
在需要监控大量文件描述符时,红黑树的高效性能表现优于链表或数组。
2. 就绪的 I/O 事件集合
当你调用 epoll_wait
时,epoll 返回的是那些 已就绪的文件描述符,即已经有 I/O 事件发生、可以立即进行读写的文件描述符。这些文件描述符组成了一个“就绪事件集合”。
数据结构:就绪的 I/O 事件集合通常使用 双向链表(双链表)来存储。
-
当某个文件描述符的 I/O 事件变为就绪时,epoll 会把这个文件描述符加入到一个双向链表中。这个链表存储了所有目前有事件发生的文件描述符。
-
当你调用
epoll_wait
时,epoll 会直接从这个双向链表中取出那些已就绪的文件描述符,并返回给用户空间。 -
由于双向链表的插入和删除操作都很快,可以在 O(1) 的时间内完成,因此这非常适合动态管理那些随时可能变为就绪状态的文件描述符。
为什么使用双向链表:
-
当文件描述符变为就绪时,只需将它加入到链表的末尾,效率高。
-
处理就绪事件时,只需遍历链表中的就绪文件描述符即可,不需要重新扫描所有注册的文件描述符。