554
社区成员
发帖
与我相关
我的任务
分享------一个网络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>
#include <sys/select.h>
以上是需要引的头文件 以下是main函数中具体的代码实现
#if 0
int 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
#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。
我们建立一个函数:
每有一个新的客户端 就创建出来一个线程专门负责
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 把收到的数据原样发回去。
#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多路复用
#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(负责收发数据或断开),从而在单线程下实现同时处理多个连接。
/*先把监听 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 回显服务器。它先把监听 socket sockfd 加入监视列表,并设置只关心 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对象 把监听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 对象,把监听 socket sockfd 加入进去,监听它的可读事件。
然后进入死循环,不断调用 epoll_wait 等待事件发生。
如果返回的是 sockfd,说明有新的客户端连接到来,此时调用 accept 得到新的 clientfd,再把这个 clientfd 也加入 epoll 中继续监听。
如果返回的是某个客户端 clientfd 的可读事件,说明客户端发送了数据,此时调用 recv 读取。
若 recv 返回 0,表示客户端断开连接,需要 close 并从 epoll 中删除。
若读取成功,则调用 send 将数据原样发回去,从而实现回显。
epoll不用再像select和poll去遍历扫描很多次,直接在已经就绪的fd中进行,不需要扫描也不需要拷贝资源。实现io多路复用。
零声学习资源:https://github.com/0voice
以上是io及多路复用的学习笔记~