2.1.1 网络IO与IO多路复用
2.1.1.1思考以下操作,与网络IO有什么关系呢?
在生活中,我们使用微信,发送文字,视频,语音,
刷抖音时,打开一个视频,视频资源怎么到达我们的APP
的?
github/gitlab , git clone 为什么代码能到达本地?
扫描共享单车二维码,能够打开车锁,
通过APP操纵空调
王者荣耀 释放技能 造成了伤害
server <-> client
以上流程中都有着server <-> client之间的交互,网络IO在日常生活中数据传输和交互中的重要作用。
2.1.1.2 客户端和服务端 进行通信:
2.1.1.2.1 简易的客户端:
2.1.1.2.2 仅链接一次的简易服务端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(3264);
if(-1 == bind(sockfd,(struct sockaddr*)&servaddr,sizeof(struct sockaddr))) { std::cout <<"bind failed :\n" ; return -1; } listen(sockfd,10); printf("listen finished\n"); struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); int clientfd = accept(sockfd, (struct sockaddr *)&servaddr, &len); while (1) { printf("accept finished\n"); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); if(count==0){ std::cout <<"client closed!"<<std::endl; close(clientfd); return 0; } printf("RECV:%s\n", buffer); } printf("exit\n"); return 0; }
|
- 端口被绑定以后,不可再次绑定
- 执行listen以后,可以看到io的状态
- 进入listen可以被连接,并且会产生新的连接状态LISTEN。并且客户端可以发送数据
- fd是io,Tcp链接是accept建立连接:
fd
(文件描述符)和 TCP
(传输控制协议)
- C 或 C++ 中,当使用
socket()
创建网络套接字时,返回的就是一个文件描述符(即 int
类型),它可以被用在 read()
、write()
、recv()
和 send()
等系统调用中与网络通信。
- 文件描述符是一个操作系统的抽象,是一个非负整数,用于引用已打开的文件。可以是常规文件、目录、设备文件,也可以是网络套接字。用于管理打开的文件或套接字。
- 而 TCP 是一个协议,定义了如何在网络上传输数据。
2.1.1.2.3 sockfd
sockfd 起始值为3, 0,1,2是系统默认已经封装好的fd,可以在/dev/fd 下看到
- sockfd 0:stdin
- sockfd 1:stdout
- sockfd 2:stderr
sockfd系统,sockfd是逐渐递增的,若前面有完全断开的clientfd,则会从最开始的clientfd接着向后创建
类似于: 3 4 5 3(close) 6 7 3(TIME_WAIT后再次可用)
系统中的fd限制:(linux一切皆文件)
在close以后,一个fd要等一段时间才能再度使用,在close以后,需要等待IO回收的时间(TIME_WAIT), 一般是60秒,可以通过
1 2 3 4 5 6
| sysctl net.ipv4.tcp_fin_timeout 修改 tcp_fin_timeout tcp_fin_timeout 参数控制 TIME_WAIT 状态的持续时间(以秒为单位)
sudo sysctl -w net.ipv4.tcp_fin_timeout=30 这会将 TIME_WAIT 的持续时间设置为 30 秒。
|
进行修改。
2.1.1.2.4 一请求一线程的处理方案
如何做到一个服务器处理同时多个客户端的链接呢?我们可以考虑在循环内为每个请求创建一个线程,対之进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
| #define THREAD_CREATE_FUNC 0 #if THREAD_CREATE_FUNC void *client_thread(void *clientfd) { int client_fd = *(int *)clientfd; while (1) {
printf("accept finished\n"); char buffer[1024] = {0}; int count = recv(client_fd, buffer, 1024, 0); if (count == 0) { close(clientfd); std::cout << "client closed!" << std::endl; return 0; } printf("RECV:%s\n", buffer); } } #else void client_thread(int clientfd) { while (1) {
printf("accept finished\n"); char buffer[1024] = {0}; int count = recv(clientfd, buffer, 1024, 0); if (count == 0) { close(clientfd); std::cout << "client closed!" << std::endl; return; } printf("RECV:%s\n", buffer); } } #endif int main() { int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(3264);
if (-1 == bind(sockfd, (struct sockaddr *)&servaddr, sizeof(struct sockaddr))) { std::cout << "bind failed :\n"; return -1; } listen(sockfd, 10); printf("listen finished\n");
#if THREAD_CREATE_FUNC struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); while (1) { int clientfd = accept(sockfd, (struct sockaddr *)&clientaddr, &len); if (clientfd >= 0) { pthread_t thread_id; pthread_create(&thread_id, NULL, client_thread, (void *)&clientfd); } } #else struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); while (1) { int clientfd = accept(sockfd, (struct sockaddr *)&clientaddr, &len); if (clientfd >= 0) { std::thread t1(client_thread, clientfd); t1.detach(); } } #endif
printf("exit\n"); return 0; }
|
一请求一线程的处理方案
资源消耗高
- 线程开销:每个线程都需要一定的系统资源,包括线程栈、线程控制块等。创建大量线程会消耗更多的内存和 CPU 资源,可能导致系统性能下降。
- 上下文切换:线程过多会导致频繁的上下文切换,这会消耗 CPU 时间,影响应用程序的整体性能。
可扩展性差
- 当并发连接数目增加时,系统可能会达到最大线程数的限制,无法再创建新线程。这种情况下,新的请求可能被拒绝,降低了系统的可用性。
- 在高并发环境下,创建和管理大量线程的复杂性增加,会增加开发和维护成本。
负载均衡问题
- 在某些情况下,同一时间处理的请求可能会导致某些线程长时间处于活动状态,而其他线程却处于空闲状态,从而造成资源的不均匀分配。
设计复杂性
- 需要处理多线程带来的同步和共享数据的问题,这会增加代码的复杂性和潜在的错误风险。
- 延迟
- 对于短暂的小请求,线程的创建和销毁可能比请求本身的处理时间还要长,因此会引入不必要的延迟
一请求一线程是非常不利于大并发的,只能做到并发量1000左右(而且会很卡),这就引入了IO多路复用。
2.1.1.2.5 IO多路复用
一请求一线程的处理方案在高并发,大量请求的情况下,会消耗大量的资源,也会有负载均衡的问题和重复多次创建线程的过程,这很浪费CPU时间
网络IO处理有俩类:
1. accept->listenfd
2. recv/send -> clientfd
① select:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| fd_set rfds ,rset,wset,errset; FD_ZERO(&rfds); FD_SET(sockfd,&rfds); int max_fd = sockfd; while(1) { rset = rfds; int nready = select(maxfd+1,& rset,& wset,& errset, NULL); char buffer[1024] = {0}; if (FD_ISSET(sockfd, &rset)) { sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); int clientfd = accept(sockfd, (struct sockaddr *)&clientaddr, &len);
std::cout <<"clientfd: "<< clientfd << "ip:" << inet_ntoa(clientaddr.sin_addr) << "port:" << ntohs(clientaddr.sin_port) << std::endl;
FD_SET(clientfd, &rfds); max_fd = clientfd > max_fd ? clientfd : max_fd;
} int i = 0; for (int i = sockfd + 1; i <= max_fd; i++) { if (FD_ISSET(i, &rset)) { int ret = recv(i, buffer, 1024, 0); if (ret == 0) { printf("client disconnect:%d \n", i); close(i); FD_CLR(i,&rset); continue; } std::cout << string(buffer) << std::endl; } } }
|
fd_set是什么?:
- 是一个比特位集合, 中间默认定义为1024 位
Select 的特点:
每次调用需要把所有fd_set集合,从用户空间copy到内核空间,如果fd的量很大的话,拷贝的消耗会很大
maxfd,遍历每个fd,如果fd的量很大的话,遍历的消耗会很大
优点:实现了IO多路复用 缺点:参数太多!
② poll 在实现了IO多路复用后,还简化了select的流程:
在poll里面用到的结构体:
1 2 3 4 5 6 7 8
| struct pollfd { int fd; short int events; short int revents; };
|
简单的poll服务端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| struct pollfd fds[1024] = {0}; fds[sockfd].fd = sockfd; fds[sockfd].events = POLLIN;
int max_fd = sockfd; while (1) { int nready = poll(fds, max_fd + 1, -1); if (fds[sockfd].revents & POLLIN) { struct sockaddr_in clientaddr; socklen_t len = sizeof(clientaddr); int clientfd = accept(sockfd, (struct sockaddr *)&clientaddr, &len); std::cout << "clientfd: " << clientfd << "ip:" << inet_ntoa(clientaddr.sin_addr) << "port:" << ntohs(clientaddr.sin_port) << std::endl; max_fd = max_fd > clientfd ? max_fd : clientfd; fds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; } for (int i = 0; i < max_fd+1; i++) { if(fds[i].revents & POLLIN) { char buffer[1024] = {0}; int count = recv(i,buffer,1024,0); if(count == 0){ printf("client disconnect:%d\n",i); fds[i].fd = -1; fds[i].events = 0; close(i); } printf("%s\n",buffer); } } }
|
poll有没有独特的使用场景?
- 简单性:
poll
的接口相对简单,适合用于小范围的文件描述符监视。对于不需要处理大量并发连接的简单应用,poll
更容易理解和实现。
- 非特定操作系统支持:
poll
是 POSIX 标准的一部分,因此在许多 UNIX 和类 UNIX 系统上都能使用,具有更好的跨平台兼容性。
- 适用于文件描述符数量较少的场景:当需要监视的文件描述符数量相对较少时(比如少于 100 个),
poll
的性能可以与 epoll
相当,且实现起来更为简单。
- 动态添加和移除观察文件描述符:尽管
epoll
允许更高效地处理大量文件描述符的动态添加和删除,但在某些情况下,比如需要频繁变化的文件描述符集,使用 poll
的灵活性可能更好。
- 支持从未就绪状态开始:
poll
可以在任何时候返回未准备好的文件描述符,epoll
在某些情况下必须提前注册。
③:epoll
epoll的重要程度:在linux2.4以前,没有linux做服务器的,也没有云主机。当时的server都是用windows/Unix
而现在云主机基本都用的linux系统。核心原因是因为 linux在2.6时引入了epoll,使得server段做到能将IO的数量做到更多。但select和poll不行,因为他们会将对应的set拷贝进去,并必须逐个遍历,极度的消耗内存,性能。
接口:
1 2 3
| int epoll_create(int size); int epoll_ctl(int epfd,int op,int fd,struct epoll_event * _Nullable event; int epoll_wait(int epfd, sturct epoll_event * events,int maxevents, int timeout);
|
结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
struct epoll_event { uint32_t events; epoll_data_t data; } __EPOLL_PACKED;
|
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
|
int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd;
sockaddr client_addr; socklen_t len = sizeof(client_addr);
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 + 1; i++) { int confd = events[i].data.fd; if(confd == sockfd) { int clientfd = accept(sockfd,(sockaddr*)&client_addr,&len); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev);
printf("accept finished:%d\n",clientfd); } else if(events[i].events & EPOLLIN) { char buffer[1024] = {0}; int count = recv(confd, buffer, 1024, 0); if (count == 0) { printf("client disconnect!\n"); epoll_ctl(epfd,EPOLL_CTL_DEL,ev.data.fd,NULL); close(i); continue; } printf("clientfd: %d msg=%s", i, buffer); send(confd,buffer,count,0); printf("send\n"); } } }
|
- 事件驱动模型:
epoll
使用事件通知机制,只返回那些状态发生变化的文件描述符。这样,程序只需处理实际有事件发生的描述符,而不是遍历所有可能的描述符。
- 高效的内核实现:
epoll
在内核中维护一个红黑树来跟踪注册的文件描述符,以及一个双向链表来管理就绪的文件描述符。这种数据结构使得插入、删除和查找操作都非常高效,不需要遍历所有描述符。
- 减少系统调用开销:
- 通过
epoll_wait
,用户空间程序只需一次系统调用就能获取所有就绪的文件描述符,避免了像 select
或 poll
那样需要多次调用来检查每个描述符的状态。