关于网络io,我们可以通过一个服务端-客户端的示例来了解:
这是一段TCP服务端的代码:
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main() {
//open
//创建网络io
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // io
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(struct sockaddr_in)); // 192.168.2.123
servaddr.sin_family = AF_INET;
//INADDR_ANY绑定任意网卡,接收任意网卡的数据
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
servaddr.sin_port = htons(9999);
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s", strerror(errno));
return -1;
}
listen(sockfd, 10);
}
值得注意的是htonl
和htons
都是将主机字节序转换为网络字节序
htonl
表示转换四字节的无符号整数,htons
表示转换两字节的无符号整数
htonl, htons, ntohl, ntohs - convert values between host and network byte order
运行这段程序,可以发现程序没有任何效果,直接退出,而当我们在listen
后面加上
getchar();
后,程序阻塞,这时通过命令netstat -anop | grep 9999
查看端口状态:
发现该端口正处于listen
状态,这时通过网络调试助手(充当客户端)连接192.168.209.130:9999发现连接成功。
服务端其实一直都处于listen
状态,之后还需要通过accept
接受连接
listen(sockfd, 10);
struct sockaddr_in clientaddr;
socklen_t len = sizeof(struct sockaddr_in);
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
getchar();
accept
接受客户端的连接,并通过传出参数返回客户端的信息,以及函数返回clientfd
,后续该客户端的数据收发都通过该clientfd
进行。这也就反应了一个问题,每个客户端连接的都会对应一个clientfd
。这时运行程序,程序阻塞等待客户端的连接,但这次是阻塞在accept
系统调用上。创建的sockfd
默认是阻塞的。
而关于阻塞和非阻塞的概念,简单总结就是阻塞会等待有事件发生,非阻塞则是不管有无事件都会立即返回。
我们将sockfd
设为非阻塞形式,并将getchar()
注释掉再看看效果:
#include <fcntl.h>
...
listen(sockfd, 10);
//设为非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
flags |= O_NONBLOCK;
fcntl(sockfd, F_SETFL, flags);
struct sockaddr_in clientaddr;
...
//getchar();
调用程序发现,程序立即返回,不再阻塞!
现在思考一个问题,连接成功是在listen
完成,还是在accept
完成呢?
我们可以在listen
后加上一个sleep(10);
,在accept
返回后打印一下返回值
listen(sockfd, 10);
sleep(10);
...
struct sockaddr_in clientaddr;
socklen_t len = sizeof(struct sockaddr_in);
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("clientfd: %d\n", clientfd);
程序运行后,立马连接,发现可以连接成功,并且10秒后,accept
返回4
说明在listen
连接就已经建立成功了,而clientfd
为4则是因为,标准输入、标准输出、标准错误、以及sockfd
已经占用了0、1、2、3再分配的文件描述符就是4。
接下来进行数据的收发(使用阻塞模式),accept
之后调用recv
和send
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret, 0);
这时收发数据只能进行一次,我们可以加上while
循环实现循环收发。若想实现多个客户端连接,并支持收发数据,也把accept
放入while循环??形如这样?
while (1) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret, 0);
}
我们开多个客户端连接发现确实能连接上服务端,但连接后仍然只能进行一次数据收发。
因为一直阻塞在accept
上,服务端只会服务新来的连接的一次数据收发。
那要支持多个客户端连接,并且都能进行多次数据收发该如何做呢?
我们可以将数据收发的工作放在一个线程中循环做:
#include <pthread.h>
void *client_thread(void *arg) {
int clientfd = *(int*)arg;
//线程中循环数据收发
while (1) {
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);
//recv返回0说明对端关闭连接
if (ret == 0) {
close(clientfd);
break;
}
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret, 0);
}
}
...
while (1) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
pthread_t threadid;
pthread_create(&threadid, NULL, client_thread, &clientfd);
}
...
来一个连接,创建一个线程,该线程循环负责该客户端的数据收发。
但是这种模式存在一个弊端,成千上万个客户端连接,难道要创建对应个数的线程吗?有没有更好的解决办法?有,那便是IO多路复用!
Linux中有三种IO多路复用:select、poll、epoll
下面介绍使用select
和poll
的方式:
#include <sys/select.h>
#define BUFFER_LENGTH 1024
listen(sockfd, 10);
struct sockaddr_in clientaddr;
socklen_t len = sizeof(struct sockaddr_in);
fd_set rfds, rset;
FD_ZERO(&rfds);
FD_SET(sockfd, &rfds);
int maxfd = sockfd;
int clientfd = 0;
while (1) {
rset = rfds;
//这里传入文件描述符最大值加1
//判断时是形如for(; i < maxfd; i++)所以要加一
int nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
if (FD_ISSET(sockfd, &rset)) {
clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept: %d\n", clientfd);
FD_SET(clientfd, &rfds);
if (clientfd > maxfd) maxfd = clientfd;
if(--nready == 0) continue;
}
int i = 0;
for (i = sockfd + 1; i <= maxfd; i++) {
if (FD_ISSET(i, &rset)) {
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(i, buffer, BUFFER_LENGTH, 0);
if (ret == 0) {
close(i);
break;
}
printf("ret: %d, buffer: %s\n", ret, buffer);
send(i, buffer, ret, 0);
}
}
}
getchar();
值得注意的是select是通过判断fd_set中的某些位,从而判断是否发生事件,因此,select所能处理的文件描述符个数是有限的,只有1024个。
下面是poll
的使用方式:
#include <poll.h>
#define POLL_SIZE 1024
listen(sockfd, 10);
struct sockaddr_in clientaddr;
socklen_t len = sizeof(struct sockaddr_in);
struct pollfd fds[POLL_SIZE] = {0};
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN;
int maxfd = sockfd;
int clientfd = 0;
while (1) {
int nready = poll(fds, maxfd + 1, -1);
if (fds[sockfd].revents & POLLIN) {
clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept: %d\n", clientfd);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd;
if(--nready == 0) continue;
}
int i = 0;
for (i = 0; i <= maxfd; i++) {
if (fds[i].revents & POLLIN) {
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(i, buffer, BUFFER_LENGTH, 0);
if (ret == 0) {
fds[i].fd = -1;
fds[i].events = 0;
close(i);
break;
}
printf("ret: %d, buffer: %s\n", ret, buffer);
send(i, buffer, ret, 0);
}
}
}
getchar();
poll
相较select
,支持的文件描述符数量不受限制,并且每次调用无需重新设置事件,因为内核不会修改,而是通过revent
返回。但是他们都有性能瓶颈,他们返回就绪的文件描述符个数,但仍需我们自己去遍历到底是哪个文件描述符上有事件,而epoll
解决了这种问题。
文章参考与<零声教育>的C/C++linux服务期高级架构系统教程学习:https://ke.qq.com/course/417774?flowToken=1020253