------一个网络io与io多路复用的学习笔记
#include <errno.h> #include <stdio.h> #include <sys/socket.h> #include <netinet/in.h> #include <string.h> #include <pthread.h> #include <unistd.h> #include <poll.h> #include <sys/epoll.h>以上是需要引的头文件 以下是main函数中具体的代码实现
1.第一种
#if 0int sockfd = socket(AF_INET,SOCK_STREAM,0); //IPv4 地址族。流式套接字(tcp) 创建一个tcp socket struct sockaddr_in servaddr; //定义服务器地址结构体 告诉操作系统:用什么协议族 绑定哪个 IP 绑定哪个端口 memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = AF_INET; //前面 socket用的是 AF_INET,这里也必须使用 servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 设置服务器的 IP 地址。 //sin_addr 是一个 IP 地址结构 s_addr 是里面真正存放 IP 的整数形式 INADDR_ANY 的值本质上表示:绑定本机所有可用网卡地址 0.0.0.0 //htonl : 把主机字节序转换成网络字节序。 servaddr.sin_port = htons(2000); //设置端口号 0 ~ 1023 是知名端口,很多系统服务会用,普通用户程序最好别乱占 1024 以上更适合自己写程序测试 if(bind(sockfd,(struct sockaddr*)&servaddr,sizeof(struct sockaddr)) == -1){ printf("bind failed : %s\n",strerror(errno)); }//bind : 把 socket 和一个具体的地址绑定起来 第二个参数需要强转强转成 struct sockaddr* listen(sockfd,10); //把这个 socket 从“普通 socket”变成“监听 socket” 10:等待队列里最多允许积压多少个还没处理的连接请求 printf("listen finished\n"); struct sockaddr_in clientaddr; //创建一个用来存“客户端地址信息”的结构体 socklen_t len = sizeof(clientaddr); //接字相关 API 规定地址长度参数一般用:socklen_t这段代码整体做的事情,其实就是从零开始建立一个小型的TCP服务器:先用socket(AF_INET, SOCK_STREAM, 0)创建一个基于 IPv4 + TCP 的套接字,相当于向操作系统申请了一个“通信端点”;接着定义一个struct sockaddr_in servaddr用来描述服务器自己的地址信息(包括协议族、IP、端口),并用memset清零避免脏数据,然后设置sin_family = AF_INET指定 IPv4,sin_addr.s_addr = htonl(INADDR_ANY)表示绑定本机所有网卡(也就是 0.0.0.0,任何发到这台机器的连接都能接),sin_port = htons(2000)指定监听端口,同时通过htons/htonl把主机字节序转换为网络字节序保证跨平台一致;之后调用bind把这个 socket 和刚才配置好的“IP + 端口”绑定在一起,这一步相当于正式告诉操作系统“我要在这个地址上提供服务”,如果失败就打印错误信息;再调用listen(sockfd, 10)把这个普通 socket 转成监听 socket,并设置一个长度为 10 的“半连接/全连接等待队列”,表示最多可以有 10 个还没被accept处理的客户端连接在排队;最后定义clientaddr和len是为后续accept做准备,用来接收客户端的地址信息(谁连上来了),服务器完成从“创建 → 绑定 → 监听”的全部准备工作,接下来只差accept就可以正式接收客户端连接了。
但是这里如果加上accept,会有一些问题,我们只有一个监听sockfd和一个clientfd,也就是我一次只能与一台服务器进行通信 也就是只可以实现单路io
下面是while循环来多次创建的情况 也无法实现多路io
2.第二种
#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); }第一次创建fd 会执行accept 然后在recv之前 这个循环会阻塞在recv,我同时再想要去用另一个客户端连接的时候 无法执行accept,因为循环阻塞在recv了,只有我接受到第一个客户端的消息,send回消息,本次循环结束之后,才能开下一个客户端,依然无法实现多路io。
我们建立一个函数:
client_thread
每有一个新的客户端 就创建出来一个线程专门负责
void *client_thread(void *arg){ //它通常会被 pthread_create() 创建出来,专门负责处理一个已经连接上的客户端。 //main中每当有一个新的客户端连进来,主线程就会:得到一个新的clientfd 再创建一个线程 把这个clientfd 交给这个线程去处理 于是每个客户端都由一个单独线程负责。 int clientfd = *(int *)arg; //传进来的是 clientfd 的地址 强转成int再解引用 拿到socket 保存到clientfd里面 while(1){ //while循环: 只要这个客户端没断开,这个线程就一直给它服务。 char buffer[1024] = {0}; int count = recv(clientfd,buffer,1024,0); //从这个客户端 socket 里读取数据,放进 buffer 里。 if(count <= 0){ //接收到的字符数为0 就break -1:接收错误也break printf("client disconnect : %d\n",clientfd); close(clientfd); //关闭这个fd break; } // parser :这里可以放一些业务 对收到的数据解析处理。 printf("RECV : %s\n",buffer); //把收到的数据按字符串打印出来。 count = send(clientfd,buffer,count,0); //把刚才收到的那 count 个字节,原封不动发回给客户端。 printf("SEND : %d\n",count); //打印的是send返回值:接受到了多少字符。 } return NULL; } /*这个函数的工作流程:从参数里取出客户端socket 反复接收客户端发来的数据 打印收到的数据 再把收到的数据发回去 果客户端断开,就关闭 socket,结束线程。 每来一个客户端,就新开一个线程去跑这个函数。这样多个客户端就能“同时”被处理了。属于多线程并发模型*/这个client_thread函数本质上是一个“客户端处理线程函数”,它负责专门服务某一个已经连接上的客户端:先从传进来的参数里取出客户端 socket 描述符clientfd,然后进入死循环,不断调用recv接收客户端发送的数据;如果返回值是 0,说明客户端已经断开连接,于是打印断开信息、关闭 socket,并退出循环结束线程;如果成功收到数据,就先打印出来,再调用send把收到的数据原样发回去。
在main函数的分支:
#elif 1 while(1){ printf("accept\n"); int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len); //接受一个客户端连接 printf("accept finished:%d\n",clientfd); pthread_t thid; //定义线程 ID pthread_create(&thid,NULL,client_thread,&clientfd); //新建一个线程,让它从 client_thread 这个函数开始执行,并把 clientfd 传给它。 }主线程始终运行在死循环中,不断调用accept等待新的客户端连接,一旦有客户端连进来,accept就返回一个新的clientfd,这个clientfd不再是监听 socket,而是专门用于和这个客户端进行通信的连接 socket;随后主线程通过pthread_create创建一个新线程,并把这个客户端的clientfd交给线程函数client_thread去处理,而主线程自己不会和这个客户端继续通信,而是立刻回到accept继续等待下一个客户端,因此多个客户端到来时,就会有多个线程分别同时处理各自的收发过程;在线程函数中,会不断对该客户端执行recv接收数据、打印数据、再用send把数据原样发回去,直到客户端断开连接,此时关闭对应的clientfd并结束线程,所以这套代码已经可以实现多客户端并发处理。
做到了一请求一线程,可以多个客户端一起处理
但是不利于并发,客户端数量太多,内核负担过重,占用资源比较多,下面引入io多路复用
select做法
#elif 0 fd_set rfds,rset; //fd_set:一个“集合”,里面记录了哪些 fd 需要被监视。 //rfds:原始监视集合 rset:本次 select 使用的临时集合 因为每一次监视都需要修改 我们不能直接改原始的集合 要用总名单复制出一个临时名单,这一轮拿临时名单去检测。 //比如当前我要盯这些 fd:3:监听 socket 4:客户端 A 5:客户端 B 8:客户端 C 那这个 fd_set 里就记着:3, 4, 5, 8 FD_ZERO(&rfds); //清空集合 rfds 先把监视名单清空 FD_SET(sockfd,&rfds); //把 sockfd 加入集合 服务器第一件事就是要知道:有没有新客户端来连接。 int maxfd = sockfd; //maxfd:当前所有被监视 fd 中,最大的那个 fd 值。 一开始就加入了sockfd,所以最大的只能是他 while(1){ rset = rfds; //把“总监视名单”复制给“本轮检测名单”。 int nready = select(maxfd+1,&rset,NULL,NULL,NULL);//调用 select 这里后面几个参数是:&rset:监视“可读事件" NULL:不监视可写 //NULL:不监视异常 NULL: 无限阻塞等待 如果没有任何 fd 可读,就一直卡在这里。 一旦有 fd 可读,就返回。 if(FD_ISSET(sockfd,&rset)){ //判断监听 socket 是否就绪 也就是是否有新的客户端进来 socket就绪 就accept int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len); //接受连接,生成一个新的客户端 fd。 printf("accept finished: %d\n",clientfd); FD_SET(clientfd,&rfds); //把这个新的客户端 socket 放进总监视名单。 if(clientfd > maxfd) maxfd = clientfd; //更新最大 fd。 } //recv int i = 0; for(i = sockfd + 1;i <= maxfd;++i){ //遍历所有客户端 fd,看看谁发数据了 从sockfd+1开始的原因是 一般先有sockfd,然后其他的clientfd都更大 if(FD_ISSET(i,&rset)){ //这个客户端 fd 可读,可能有数据,也可能断开了。 char buffer[1024] = {0}; int count = recv(i,buffer,1024,0); //recv 接收 if(count == 0){ //count == 0 证明断开了 printf("client dosconnect: %d\n",i); close(i); //关闭本地 fd FD_CLR(i,&rfds); //从总监视集合中删除 continue; } printf("RECV: %s\n",buffer); //正常收到数据,回显回去 count = send(i,buffer,count,0); printf("SEND: %d\n",count); } } }这段代码实现的是一个基于select的单线程多路tcp服务器
程序首先创建一个fd_set类型的集合rfds作为“总监视集合”,初始化时只把监听 socket(sockfd)加入进去,同时用maxfd记录当前集合中最大的文件描述符。进入主循环后,每一轮都会先把rfds复制到临时集合rset,然后调用select(maxfd+1, &rset, NULL, NULL, NULL)阻塞等待事件发生。select返回后,rset中保留下来的 fd 就是“本轮就绪的 fd”,也就是需要处理的对象。
接着程序先判断监听 socket 是否在rset中,如果在,说明有新的客户端连接到来,于是调用accept创建新的clientfd,并把它加入到rfds中,同时更新maxfd,这样后续就能一起监视这个客户端。
然后程序通过for循环遍历sockfd+1到maxfd的所有可能客户端 fd,对每个 fd 用FD_ISSET(i, &rset)判断是否在本轮就绪集合中。如果在,说明这个客户端 socket 可读,就调用recv读取数据:如果返回值为 0,说明客户端断开连接,此时关闭 fd 并从rfds中移除;如果大于 0,说明收到了数据,就通过send原样发回,实现一个简单的回显(echo)功能。
整个程序的本质就是:维护一个“被监视的 fd 集合”,每一轮通过select找出“当前有事件的 fd”,然后分别处理监听 socket(负责接新连接)和客户端 socket(负责收发数据或断开),从而在单线程下实现同时处理多个连接。
下面是poll的做法:
/*先把监听 socket sockfd 加入监视列表 用 poll() 一直等待: 有没有新客户端连接 有没有某个客户端发来数据 如果有新连接,就 accept 如果有客户端发数据,就 recv 收到后再 send 回去,形成回显服务器 如果客户端断开,就关闭并从监视列表中移除*/ #elif 1 struct pollfd fds[1024] = {0}; //定义了一个 pollfd 数组,大小是 1024。 fds[sockfd].fd = sockfd; //把监听 socket 放进 fds 数组中。 fds[sockfd].events = POLLIN; //关心可读事件 响应了就是有新的客户端连接来了,可以 accept 了。 int maxfd = sockfd; //这个 maxfd 表示:当前监视范围内最大的 fd 值 while(1){ int nready = poll(fds,maxfd+1,-1); //三个参数 :1.监视的数组 2.数组前多少项要参与检查。3.-1表示永久阻塞等待 if(fds[sockfd].revents & POLLIN){ //看监听 socket 这次返回后,实际发生的事件里,是否包含 POLLIN。包含了就有新的事件 revents是实际发生的事件 int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len); //accept 新连接 printf("accept finished: %d\n",clientfd); fds[clientfd].fd = clientfd; //把新客户端也加入 poll 监视列表 fds[clientfd].events = POLLIN; if(clientfd > maxfd) maxfd = clientfd; // 更新最大fd } 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){ //disconnect 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: %d\n",count); //打印数据 回发数据 } } }这段代码是一个基于poll的多路 IO 回显服务器。它先把监听 socketsockfd加入监视列表,并设置只关心POLLIN读事件,因为监听 socket 一旦可读,就说明有新的客户端连接到来,可以调用accept。进入死循环后,服务器通过poll(fds, maxfd + 1, -1)一直阻塞等待事件发生。若监听 socket 的revents中包含POLLIN,就说明有新连接,此时accept得到新的clientfd,再把这个客户端 fd 也加入fds数组继续监视。随后程序遍历所有客户端 fd,如果某个客户端的revents中有POLLIN,就调用recv接收数据;如果recv返回 0,说明客户端断开连接,此时关闭该 fd,并把它在fds数组中的位置置为无效;如果成功收到数据,就打印出来,再通过send原样发回去,实现回显功能。整个服务器只用一个线程,但可以同时管理多个客户端,这就是poll多路复用的基本思想。
epoll做法:
//流程:创建epoll对象 把监听socket(sockfd)加入epoll while(1): epoll_wait阻塞等待事件发生 遍历所有就绪事件 如果是sockfd有事件 → 说明有新连接 → accept 把新的clientfd加入epoll 如果是某个clientfd可读 → recv 如果recv返回0 → 客户端断开close + 从epoll删除 否则send回去 #else 1 int epfd = epoll_create(1); //创建一个 epoll 实例。 struct epoll_event ev; //准备一个事件结构体,把sockfd加进去 ev.events = EPOLLIN; //关心“可读事件” ev.data.fd = sockfd; //这个事件结构体对应的是 sockfd 也就是现在要把监听 socket 注册到 epoll 中 epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&ev); //把 sockfd 加入到 epfd 这个 epoll 实例中,并且监听它的 EPOLLIN 事件 //四个参数 1.哪一个epoll示例 也就是传一个epfd,2.执行什么操作 这里是添加 3.要操作的fd,4.这个fd的具体监听配置 传一个结构体 while(1){ struct epoll_event events[1024] = {0}; //这个events不是用来看要监听谁 而是数组用来接收 epoll_wait 返回的“已经就绪的事件” int nready = epoll_wait(epfd,events,1024,-1); //阻塞等待,直到某些 fd 上发生了你关心的事件。 //第一个参数 epfd:哪个epoll实例 第二个参数events用来接收返回的就绪事件数组 第三个参数 1024 最多返回多少个就绪事件 第四个参数 -1 阻塞等待,直到有事件发生 int i = 0; for(i = 0;i < nready;i ++){ int connfd = events[i].data.fd; //取出当前的fd if(connfd == sockfd){ //如果是监听fd int clientfd = accept(sockfd,(struct sockaddr*)&clientaddr,&len); //接收这个新连接,得到一个新的客户端socket:clientfd printf("accept finished:%d\n",clientfd); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&ev); //把 clientfd 加入 epoll }else if(events[i].events & EPOLLIN){ //如果不是 sockfd,并且这个 fd 发生了可读事件 说明:某个客户端发数据来了,可以 recv char buffer[1024] = {0}; //接受客户端数据 int count = recv(connfd,buffer,1024,0);//从 connfd 这个客户端 socket 中读取数据到buffer 返回值count表示收到多少字节。 if(count == 0){ //count == 0说明断开连接了 printf("client disconnect: %d\n",connfd); //打印日志 close(connfd); //关闭 fd epoll_ctl(epfd,EPOLL_CTL_DEL,connfd,NULL); //从 epoll 中删除 continue; } printf("RECV:%s\n",buffer); //如果没断开,就说明收到了数据 打印收到的内容。 count = send(connfd,buffer,count,0); //发送回去,形成回显 } } } #endif getchar(); printf("exit\n"); return 0;服务器先创建一个epoll对象,把监听 socketsockfd加入进去,监听它的可读事件。
然后进入死循环,不断调用epoll_wait等待事件发生。
如果返回的是sockfd,说明有新的客户端连接到来,此时调用accept得到新的clientfd,再把这个clientfd也加入epoll中继续监听。
如果返回的是某个客户端clientfd的可读事件,说明客户端发送了数据,此时调用recv读取。
若recv返回 0,表示客户端断开连接,需要close并从epoll中删除。
若读取成功,则调用send将数据原样发回去,从而实现回显。
epoll不用再像select和poll去遍历扫描很多次,直接在已经就绪的fd中进行,不需要扫描也不需要拷贝资源。实现io多路复用。
零声学习资源:https://github.com/0voice
以上是io及多路复用的学习笔记~