本文主要介绍TCP并发网络的编程,重点介绍io多路复用的epoll实现
一、TCP/IP 网络通信过程
要完成一个完整的 TCP/IP 网络通信过程,需要使用一系列函数来实现。这些函数包括 bind、listen、accept 和 recv/send 等。下面是它们的配合流程:
- 创建套接字(socket):使用 socket 函数创建一个套接字,指定协议族和套接字类型。
- 绑定地址(bind):将本地地址绑定到套接字上,使得客户端可以通过该地址访问服务器。
- 监听连接请求(listen):将套接字设置为监听状态,并指定最大等待连接数(backlog)。
- 接受连接请求(accept):当有客户端发起连接请求时,使用 accept 函数创建新的套接字用于与客户端进行通信。
- 读写数据(recv/send):使用新创建的套接字进行数据传输,包括从客户端读取数据和向客户端发送数据。
- 关闭连接(close):在通信结束后,需要使用 close 函数关闭套接字以释放资源。
对于第4步的请求有两种处理方式:一线程一请求和epoll方法。
二、io多路复用的epoll 实现
epoll是一种在Linux操作系统中实现高性能I/O多路复用的机制。它可以同时监听多个文件描述符,当其中任何一个文件描述符发生读写等事件时,就会触发相应的回调函数进行处理。
基于 epoll 实现的 TCP 服务端程序的流程如下:
- 创建一个监听套接字,使用 socket() 函数创建套接字并设置相关参数(如地址重用等)。
- 将监听套接字绑定到本地 IP 地址和端口号,使用 bind() 函数将套接字与指定的地址进行绑定。
- 开始监听连接请求,使用 listen() 函数将该套接字标记为被动监听状态,并设置可同时处理的最大连接数。
- 创建一个 epoll 实例,使用 epoll_create() 函数创建 epoll 实例,并设置需要监视的事件类型。
- 将监听套接字添加到 epoll 实例中,使用 epoll_ctl() 函数向 epoll 实例中添加需要监视的文件描述符及对应事件。其中事件类型一般为 EPOLLIN 表示可读事件或者 EPOLLERR 表示错误事件。
- 进入主循环处理客户端请求,使用 epoll_wait() 等待内核通知就绪事件,并获取到就绪的文件描述符列表。然后遍历这个文件描述符列表并根据每个文件描述符对应的事件类型进行相应处理。如果是新连接请求,则调用 accept() 接收该连接,并将其加入 epoll 监听队列;否则,直接读取数据或者关闭连接等操作。
- 关闭监听套接字以及已经建立连接的客户端套接字,清理资源并退出程序。
总之,在 epoll 实现的 TCP 服务端程序中,通过使用 epoll 实例,可以同时处理多个客户端连接请求,并且在有新数据到达时能够及时地通知程序进行相应的处理。
另外,epoll还提供了ET(边缘触发)和LT(水平触发)两种工作模式。
- 水平触发模式
在水平触发模式下,如果文件描述符上的事件没有被处理完毕,epoll 会持续通知应用程序该文件描述符上仍有事件待处理。在这种情况下,如果应用程序不及时响应并读取数据,则 epoll 会一直通知应用程序该文件描述符上有数据可读取。 - 边沿触发模式
在边沿触发模式下,只要文件描述符上出现新的事件(例如数据可读或连接建立),epoll 就会通知应用程序。但是,在通知之后,如果应用程序没有立即响应并读取所有数据,则 epoll 不会再次通知该文件描述符上有新的数据可读。
总体来说,边沿触发模式相比于水平触发模式更为高效,并且可以避免由于重复监听导致 CPU 占用率过高的问题。但是,在使用边沿触发模式时需要注意及时读取所有数据,并确保每个事件都得到了正确处理。
并且,与select相比,虽然两者都是Linux下的I/O多路复用机制,但是它们有一些重要的区别:
- 监听文件描述符数量限制不同:在Linux中,select函数所支持的最大文件描述符数量默认为1024个,而epoll没有这个限制,可以监听成千上万个文件描述符。
- 文件描述符集合拷贝方式不同:在使用select时,每次调用需要将待监视的所有文件描述符从用户空间拷贝到内核空间,在返回结果之后还需要再将结果从内核空间拷贝回用户空间。这样会带来较大的性能开销。而在使用epoll时,只需将待监视的文件描述符加入一个内核事件表中即可完成注册,当有就绪事件发生时直接通知应用程序进行处理。
- 对于非阻塞套接字处理方式不同:在使用select时,对于非阻塞套接字我们需要手动设置为非阻塞模式并且轮询读写操作是否就绪。而epoll则通过设置EPOLLET标志位实现了边缘触发模式,并且对于非阻塞套接字只需要等待其返回EAGAIN错误码即可知道其已经处于非阻塞状态。
总体来说,与select相比,epoll具有更高效、更灵活、更易扩展等优点,在处理大量并发连接时具有更好的性能和可扩展性。
#include <stdio.h> #include <time.h> #include <string.h> #include <stdlib.h> // #include <winsock2.h> // #include <mswsock.h> // #include <windows.h> // #include <sys/types.h> // #include <unistd.h> // #include <fcntl.h> #include <netinet/tcp.h> #include <arpa/inet.h> #include <pthread.h> #include <errno.h> #include <fcntl.h> #include <unistd.h> #include <sys/epoll.h> #define BUFFER_LENGTH 1024 #define EPOLL_SIZE 1024 void *client_routine(void *arg){ int clientfd=*(int *)arg; while (1){ char buffer[BUFFER_LENGTH]={0}; int len=recv(clientfd,buffer,BUFFER_LENGTH,0); if (len < 0){//非阻塞状态下读到空数据 close(clientfd); break; } else if(len == 0) {//断开连接 close(clientfd); break; } else{ printf("Recv: %s, %d btye(s)\n",buffer,len); } } } int main(int argc,char *argv[]){ if (argc < 2) { printf("Param Error \n"); return -1; } int port=atoi(argv[1]);//atoi将一个字符串转换为对应的整数值 int sockfd=socket(AF_INET,SOCK_STREAM,0); struct sockaddr_in addr; memset(&addr,0,sizeof(struct sockaddr_in)); addr.sin_family=AF_INET; addr.sin_port=htons(port); addr.sin_addr.s_addr=INADDR_ANY; if (bind(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr_in))<0){ perror("bind"); return -2; } if(listen(sockfd,5)<0){ perror("listen"); return -3; } #if 0 // 一请求一线程 while (1){ struct sockaddr_in client_addr; memset(&client_addr,0,sizeof(struct sockaddr_in)); socklen_t client_len =sizeof(client_addr); /*调用 accept() 函数后,它会一直阻塞等待直到有新的客户端连接请求到达为止。 当有新的连接请求到达时,它会返回一个新产生的套接字文件描述符,并且将该连接对应的客户端地址信息存储在 addr 指向的结构体中*/ int clientfd=accept(sockfd,(struct sockaddr *)&client_addr,&client_len); pthread_t thread_id; pthread_create(&thread_id,NULL,client_routine,&clientfd); } #else /*使用epoll的基本流程如下: 1,创建一个epoll实例,可以通过调用 epoll_create() 函数来创建。 2,向 epoll 实例中添加需要监控的文件描述符及其事件类型,可以通过调用 epoll_ctl() 函数进行操作。 3,调用 epoll_wait() 函数等待监控对象上发生事件,并处理活跃的文件描述符及其事件类型。 4,处理完活跃文件描述符的相关操作后,返回到第三步继续等待新的事件发生。 */ int epfd=epoll_create(1); struct epoll_event events[EPOLL_SIZE] = {0}; //创建一个结构体数组 events 用于存储 epoll_wait() 返回的事件列表。 struct epoll_event ev; ev.events=EPOLLIN; //创建一个新的 epoll_event 结构体 ev 并设置其关注的事件类型为 EPOLLIN (表示等待读事件) ev.data.fd = sockfd; epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev); //使用 epoll_ctl() 函数将 sockfd 文件描述符加入到 epfd 实例中,并关联上面创建的 ev 结构体。 while (1){ int nready=epoll_wait(epfd,events,EPOLL_SIZE,5); // if (nready == -1) continue; //表示5秒内,没有事件,继续监听 int i=0; for (i=0;i<nready;i++){ /*判断当前事件所对应的文件描述符是否为监听套接字 sockfd。如果是,则说明有新的客户端连接请求到来了, 需要通过 accept() 函数获取新产生的客户端连接并添加到 epoll 实例中; 否则,说明是已经建立好连接的客户端发送了数据,需要通过 recv() 函数接收数据并进行相应处理。*/ if(events[i].data.fd == sockfd){ /*当有新的连接请求到来时(即 sockfd 上有 EPOLLIN 事件),使用 accept() 函数接受连接, 并将其加入 epoll 实例中关注该套接字上是否有输入事件。*/ struct sockaddr_in client_addr; memset(&client_addr,0,sizeof(struct sockaddr_in)); socklen_t client_len =sizeof(client_addr); int clientfd=accept(sockfd,(struct sockaddr *)&client_addr,&client_len); ev.events=EPOLLIN | EPOLLET; //EPOLLET 则表示将 I/O 事件设置为边缘触发模式。 ev.data.fd=clientfd; epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev); } else{ //当某个客户端套接字上出现可读事件时(即该文件描述符在 events 中对应的元素有 EPOLLIN 标志),则调用 recv() 函数从该套接字中读取数据 int clientfd=events[i].data.fd; char buffer[BUFFER_LENGTH]={0}; int len=recv(clientfd,buffer,BUFFER_LENGTH,0); if (len < 0){//出现了异常情况或者非阻塞状态下没有更多数据可读 //关闭该套接字并将其从 epoll 实例中删除 close(clientfd); ev.events=EPOLLIN; ev.data.fd=clientfd; epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,&ev); //从 epoll 实例中删除 clientfd 对应的文件描述符,并且停止监听该套接字上的事件。 } else if(len == 0) {//对方已经断开连接 //关闭该套接字并将其从 epoll 实例中删除 close(clientfd); ev.events=EPOLLIN; ev.data.fd=clientfd; epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,&ev); } else{ printf("Recv: %s, %d btye(s)\n",buffer,len); } } } } #endif return 0; }