4.1 网络基础之网络IO

一、编写基本服务程序流程

下面介绍一个最最简单的服务程序的编写流程,先按照顺序介绍各个函数的参数和使用。然后在第三节用一对简单的程序对客户端与服务端通信过程进行演示。下面所有代码均在linux平台实现,所以可能与windows上的编程有所区别,主要是相关头文件、编译方式上。

1、创建套接字

// 头文件
#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

/*
* 参数domain:通讯协议族
* PF_INET       IPv4互联网协议族(常用)
* PF_INET6      IPv6互联网协议族
* PF_LOCAL      本地通信的协议族
* PF_PACKET     内核底层的协议族
* PF_IPX        IPX Novell协议族
* IPv6尚未普及,其它的不常用
*/

/*
* 参数type:数据传输的类型
* SOCK_STREAM    面向连接的socket   数据不会丢失、顺序不会错乱、双向通道
* SOCK_DGRAM     无连接的socket     数据可能会丢失、顺序可能会错乱、传输效率更高
*/

/*
* 参数protocol:最终使用的协议
* 在IPv4网络协议家族中:
* 数据传输方式为SOCK_STREAM的协议只有IPPROTO_TCP
* 数据传输方式为SOCK_DGRAM的协议只有IPPROTO_UDP
* 本参数也可以填0,编译器自动识别
*/

/*
* socket返回值:
* 成功返回一个有效的socket,失败返回-1,errno被设置
*/

2、端口复用

// 这个步骤非必须
int opt = 1;
unsigned int len = sizeof(opt);
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, len);

这里简单介绍下SO_REUSEADDR的使用场景:

1、主动断开连方的socket1处于TIME_WAIT状态时,新创建的socket2想要绑定相同于socket1的IP和端口,就要设置SO_REUSEADDR。

2、SO_REUSEADDR允许同一端口上启动同一服务器的多个实例(即多个进程)。但每个实例绑定的IP是不能相同的。有多块网卡或用IP Alias机器可以试试。

3、SO_REUSEADDR允许单个进程绑定相同端口到多个socket上,但每个socket绑定的IP不同。

4、SO_REUSEADDR允许完全相同的IP和端口重复绑定。但只用于UDP多播,不用于TCP。

3、设置IP和端口

// 头文件
#include <netdb.h>

// 申请变量,用于存放协议、端口和IP地址
struct sockaddr_in servaddr;

// 初始化
memset(&servaddr,0,sizeof(servaddr));

// 设置协议族
servaddr.sin_family = AF_INET;

// 设置IP,本机的所有IP都可用
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);

// 指定用于监听的端口
servaddr.sin_port = htons(m_port);

4、绑定IP和端口

// 失败返回-1,成功返回0
// 注意第二个参数要强制转换类型
int bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

5、开始监听客户端

// 参数s:监听的描述符listen_fd
// 参数backlog:已经完成连接正等待应用程序接收的套接字队列长度(linux中)
// 失败返回-1,成功返回0
int listen(int s, int backlog);

6、接受连接上的客户端

struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int connet_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
// 这里需要注意,同样涉及类型强制转换
// 最后一个参数必须传指针
// 返回一个 用于处理客户端请求的 套接字描述符

7、收发数据完成业务逻辑

// 此处开启一个新的线程,来处理客户端的请求
#include <pthread.h>
pthread_t pid;
pthread_create(&pid, NULL, deal_request, (void*)(long)client_fd);

/*
* 处理客户端请求的逻辑:
* 读取数据成功,先输出,再直接发送过去
* 读取数据失败,关闭套接字
*/
void *deal_request(void* arg){
    // 处理流程
    return (void*)0;
}

二、收发数据函数简介

1、发送数据

send

ssize_t send(int sock_fd, const void *buf, size_t len, int flags);
/*
* 参数:
* sock_fd:发送给对方的网络套接字
* buf:待发送的数据的起始地址
* len:要发送的数据大小,发送最多不超过len大小的字节
* flags:用于控制发送行为,默认传0
*/

// 返回值是ssize_t,表示实际发送成功字节数。返回-1表示出错

write

ssize_t write(int sock_fd, const void *buf, size_t count);
/*
* 参数:
* sock_fd:发送给对方的网络套接字
* buf:待发送的数据的起始地址
* count:最大写入字节数
*/

补充

套接字为阻塞模式时,如果发送缓冲区无法容纳发送的数据,程序会阻塞在send和write方法。

send、write函数向套接字发送数据时,函数调用后不代表数据已经发送出去。网络协议栈有一个发送缓冲区,先将数据拷贝到发送缓冲区,然后网络协议栈将发送缓冲数据通过网卡驱动转为电信号给发送出去。

阻塞模式下,发送缓冲区空间不够,程序阻塞在send、write函数,直到发送缓冲区数据发送出去腾出空间,将剩下数据再拷贝到腾出的空间,直接到数据全部拷贝进发送缓冲区,函数返回。

2、接收数据

recv

ssize_t recv(int sock_fd, void *buf, size_t len, int flags);

/*
* 参数:
* sock_fd:从哪个套接字接收数据
* buf:接收到的数据保存到以buf为起始的地址
* len:本次最多接收多少字节的数据
* flags:控制套接字接收行为,默认传0
*/

/*
* 返回值:
* -1:出错,可以在errno取到对应的错误信息
* 大于0:实际读取到的字节数
* 0(EOF):对端没有更多数据发送了,可能对端已经把连接关闭
*/

read

ssize_t read (int sock_fd, void *buf, size_t count);

/*
* 参数:
* sock_fd:从哪个套接字接收数据
* buf:接收到的数据保存到以buf为起始的地址
* count:本次最多接收多少字节的数据
*/

/*
* 返回值:
* -1:出错,可以在errno取到对应的错误信息
* 大于0:实际读取到的字节数
* 0(EOF):对端没有更多数据发送了,可能对端已经把连接关闭
*/

补充

套接字阻塞模式下,如果调用read和recv函数时,套接字没有数据可读,程序会阻塞在read或者recv函数,直到有数据可读。

调用read、recv函数时,从接收缓冲区读数据。所以不能保证每次调用read、recv函数时一定能读出所有数据。因为,数据可能还在对端发送缓冲区,也可能还在各个中间设备(路由器、交换机、电信主干网)。

接收缓冲区中有个读指针和写指针,当调用read和recv函数时,读指针会往后移,下次读取就从新的指针处开始读,读指针移动的长度就是read和recv函数返回的实际读取到的字节长度。

三、客户端与服务端通信示例

1、server_base.c

编译为可执行文件,命令为:gcc -o server server.c -lpthread

运行时,执行命令:./server 5555。这个5555是给main的参数,端口。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h> 
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <unistd.h>


/*
* 功能: 线程函数,处理客户端的请求
*       这里打印接收到的信息,打印后再返回给客户端
*/
void *deal_request(void *arg) {
	int fd = (int)(long)arg;	// linux中指针为long型
	char buffer[1024];
	while (1) {
		memset(buffer, 0, sizeof(buffer));
		int res = recv(fd, buffer, sizeof(buffer), 0);
		if (res == 0) {		// 客户端退出连接
			close(fd);
			printf("客户端[%d]退出\n", fd);
			break;
		} else {
			printf("接收到客户端[%d]的数据是: %s\n", fd, buffer);
			send(fd, buffer, sizeof(buffer), 0);
		}
	}
	return (void*)0;
}
 
int main(int argc, char **argv) {
	if (argc != 1 + 1) {
		printf("请给一个端口参数:\n");
		return -1;
	}
	// 1、创建套接字
	int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
 
	// 2、设置端口复用
	int opt = 1;
	unsigned int len = sizeof(opt);
	setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, len);
 
	// 3、设置服务端的IP和端口
	struct sockaddr_in server_addr;
	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = htonl(INADDR_ANY);	// 本机任何IP皆可
	// server_addr.sin_addr.s_addr = inet_addr("192.168.237.10");	// 指定IP
	server_addr.sin_port = htons(atoi(argv[1]));
	
	// 4、将套接字和IP、端口绑定
	int ret;
	ret = bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
	if (ret == -1) {
		printf("bind failed\n");
		close(listen_fd);
		return -1;
	}
	
	// 5、开始监听
	ret = listen(listen_fd, 5);
	if (ret == -1) {
		printf("listen failed\n");
		close(listen_fd);
		return -1;
	}
	
	// 服务器不间断的接受客户端请求
	while (1) {
		// 6、接受连接上的客户端
		struct sockaddr_in client_addr;
		socklen_t len = sizeof(client_addr);
		int connet_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
		if (connet_fd == -1) {
			printf("accept failed\n");
			close(listen_fd);
			return -1;
		}
		
		printf("客户端[%d]连接上\n", connet_fd);
 
		// 7、创建一个线程来处理当前客户端的请求
		pthread_t pid;
		pthread_create(&pid, NULL, deal_request, (void*)(long)connet_fd);
	}
	
	return 0;
}

2、client.c

对于客户端如果想不写代码,可以使用NetAssist网络调试助手。如下图,设置好网络协议、服务端IP和端口,点击连接,即可通信。在这之前,确保服务端开放了设定的端口号,或者直接关闭防火墙。

关闭防火墙,可写成close_fire.sh文件,如下,然后运行./close_fire.sh。

#!/bin/bash
systemctl stop firewalld.service

如果也想通过代码进行通信,client.c代码则如下:

#include <stdio.h>
#include <sys/types.h> 
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
 
int main(void) {
	// 1、创建套接字
	int sockfd = socket(AF_INET, SOCK_STREAM, 0);
 
	// 2、设置服务端的IP和端口
	unsigned short port = 5555;	// 指定服务端的通信端口
	struct sockaddr_in server_addr;
	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = inet_addr("192.168.237.10");	// 指定服务端的IP
	server_addr.sin_port = htons(port);
	
	// 3、向服务端发起连接
	int ret;
	ret = connect(sockfd,(struct sockaddr *)&server_addr,sizeof(server_addr));
  	if (ret == -1){ 
	    printf("connect failed\n");
		close(sockfd);
		return -1; 
	}
 
	// 4、与服务端进行通讯
	char buffer[1024];
	for(int i = 0;i < 3;i ++) { // 此处假设与将与服务端进行三次通讯
    int iret;
    memset(buffer,0,sizeof(buffer));
    sprintf(buffer,"服务端你好,这是第%d次通信。",i + 1);  // 生成报文内容
    
    // 向服务端发送请求报文
    iret = send(sockfd, buffer, strlen(buffer), 0);
    if (iret == -1){
    	printf("send failed\n");
    	close(sockfd);
      	return -1;
    }
	
    memset(buffer, 0, sizeof(buffer));
    
    // 接收服务端响应报文,如果服务端没有发送响应报文,recv()函数将阻塞等待
	ret = recv(sockfd, buffer, sizeof(buffer), 0);
    if (ret == 0) {
		printf("服务端已经关闭");
		close(sockfd);
		return -1;
    }
    printf("接收到服务端信息:%s\n", buffer);
 
    sleep(1);	// 休眠1秒
  }
 
  // 5、正常关闭socket,释放资源
  close(sockfd);
  
  return 0;
}

3、客户端服务端代码流程对比

四、一些注意点

1、分包粘包问题

分包,例如:对方发送helloworld,我方收到hello和world。

粘包,例如:对方发送hello和world,我方收到helloworld。

解决办法:TCP协议中,数据以字节流方式传输,被发送的数据可能不是一次性发完,可能是被拆成很多个小段,一段段发出去。有个重要前提,就是TCP协议可以保证数据的顺序性。因此,可以采用报文长度 + 报文内容的方法。也可以使用特殊分隔符,如http协议采用\r\n

2、阻塞与非阻塞

阻塞:阻塞I/O调用recv、read时,程序切换到内核态。若I/O中(套接字)没有数据可读,就阻塞。直到有数据可读,内核将数据复制到用户态,复制完再返回。最后recv、read函数再返回读取到的字节数。缺点是这种方式比较占CPU。

非阻塞:非阻塞模式下,没有读取到数据立即返回-1,为了区别阻塞模式下返回-1,可以查看错误码errno设置成了EAGAIN。缺点就是需要间隔一段时间就读一下数据涉及系统调用,还是比较消耗资源。设置代码如下:

// 头文件
#include <unistd.h>
#include <fcntl.h>

fcntl(fd, F_SETFL, O_NONBLOCK);    // fd为想要设置的套接字描述符

五、几种IO复用模型

1、select

原理

select方法告诉内核程序自己关心哪些I/O描述符,当内核程序发现有I/O准备好(可写/可读/异常),内核程序将数据复制到用户态并从select返回。用户拿着准备好的IO再调用recv就一定能拿到数据。

用法和参数

int select(
  int maxfdp1,
  fd_set *readset,
  fd_set *writeset,
  fd_set *exceptset,
  const struct timeval *timeout
);

/*
* 参数:
* maxfdp1:当前待监听描述符基数。若监听的描述符最大值是3,则maxfdp1就为4(因为描述符从0开始)
* fd_set:通常有读、写、异常三种情况,readset、writeset、exceptset分别对应I/O的读、写和异常。
  表示当前关心readset里描述符是否可读,writeset里描述符是否可写,exceptset中描述符是否有异常。
* timeout:
  struct timeval {
      long tv_sec;  // 秒
      long tv_usec; // 微秒
  }
	- 传NULL,如果没有I/O可以处理,一直等待;
	- 设置成对应的秒或微秒,等待相应时间后若没有I/O可以处理就返回;
	- 将tv_sec和tv_usec都设置成0,表示不用等待立即返回。
*/

/*
* select返回值:
* -1表示出错;
* 0表示超时;
* 大于0表示可操作的I/O数量。
*/

使用流程 (服务端)

// 头文件
#include <sys/select.h>

// 1、创建监听的fd集合并初始化
fd_set readfds;    // 大小16字节,1024位
FD_ZERO(&readfds); // 每一位置0

// 2、把listen的socket加入集合
FD_SET(listensock, &readfds);  // 起初还未有客户端加进来

// 3、while循环中调用select
fd_set tmpfds = readfds;	// 复制一份fd集合,因为系统在判断时会更改送进去的集合参数
int infds = select(maxfd + 1, &tmpfds, NULL, NULL, 0);

// 4、当select返回值大于0
// 用FD_ISSET判断每个socket是否有事件
FD_ISSET(eventfd, &tmpfds)

/*
对于新连接进来的客户端加入到事件集合
FD_SET(clientsock, &readfds);
*/

/*
对于断开的客户端,将其清除
FD_CLR(eventfd, &readfds);
并将套接字关闭
close(eventfd); 
*/

触发方式

采用水平触发,如果报告fd事件没有被处理或数据没有被全部读取,下次select时会再次报告该fd事件。

缺点

1、select支持文件描述符数量太小,默认1024

2、每次调整select都需要把fdset从用户态拷贝到内核

3、在线的大量客户端同时有事件发生的可能性小,但还是需要遍历fdset,因此随着监视的描述符数量增长,效率也会线性下降。

完整服务程序server_select.c

该程序中只监听了可读的事件,并在liunx中运行,编译命令:gcc -o server server_select.c,运行命令:./server 5555。代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <string.h>

int main(int argc, char *args[]) {
	if (argc != 1 + 1) {  // 需要传入参数端口
		printf("please give port!\n");
		return 0;
	}

	// 1、创建套接字
	int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

	// 2、设置端口复用
	int opt = 1;
	unsigned int len = sizeof(opt);
	setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, len);

	// 3、设置服务端的IP和端口
	struct sockaddr_in server_addr;
	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	server_addr.sin_port = htons(atoi(args[1]));

	// 4、将套接字和IP、端口绑定
	bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

	// 5、开始监听
	listen(listen_fd, 5);

	// 6、初始化fd集和
	fd_set rfd;
	FD_ZERO(&rfd);
	FD_SET(listen_fd, &rfd);

	int maxfd = listen_fd;
	while(1) {
		fd_set tmp_fd = rfd;

		// 7、调用select
		int num = select(maxfd + 1, &tmp_fd, NULL, NULL, 0);
		if (num == -1) {
			printf("error select\n");
			close(listen_fd);
			break;
		} 
		
		if (num == 0) {		// 没有事件,继续
			continue;
		}

		// 如果listen_fd有事件
		if (FD_ISSET(listen_fd, &tmp_fd)) {
			struct sockaddr_in client_addr;
			socklen_t len = sizeof(client_addr);

			// 8、接受连接上的客户端
			int client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
			if (client_fd == -1) {
				printf("accept error\n");
			} else {
				printf("client %d was connected!\n", client_fd);
				FD_SET(client_fd, &rfd);
				if (client_fd > maxfd) maxfd = client_fd;	// 如果新的fd大于maxfd,则替换
			}
		}

		// 检查后面的fd是否有事件
		int fd = listen_fd + 1;
		for (;fd <= maxfd;fd ++) {
			if (FD_ISSET(fd, &tmp_fd) == 0) {  // 无事件
				continue;
			} else { // 9、处理已经连接上的客户端的请求
				char buffer[1024];
				memset(buffer, 0, sizeof(buffer));
				int res = recv(fd, buffer, sizeof(buffer), 0);
				if (res == 0) { // 对方断开连接
					close(fd);
					printf("client [%d] disconnected !\n", fd);
					FD_CLR(fd, &rfd);	// 从集和中清除
				} else {
					printf("recv data from [%d] is: %s\n", fd, buffer);
					send(fd, buffer, sizeof(buffer), 0);
				}
			}
		}
	}

	return 0;
}

2、poll

原理

与select本质上没有差别,管理多个描述符也进行轮询,根据描述符状态进行处理,但是poll没有最大文件描述符数量的限制。

用法和参数

int poll(
    struct pollfd *fdarr,
    unsigned long nfds,
    int timout
);

/*
* 参数:
* fdarr:要监听的I/O描述符事件集合,其结构如下:
  struct pollfd {
      int fd; // 描述符
      short events;  // 监听描述符发生的事件
      short revents; // 已经发生的事件
  };
* nfds:要监听的套接字数量
* timeout:超时时间,一般有三种传值的方式
	- -1表示在有可用描述符之前一直等待;
	- 0表示不管有没有可用描述符都立即返回;
	- 大于0表示超过对应毫秒即使没有事件发生也会立即返回
*/

/*
* poll返回值:
* -1表示出错;
* 0表示超时;
* 大于0表示可操作的I/O数量。
*/

使用流程 (服务端)

// 1、声明struct pollfd类型数组fds
int maxfd = 2047;    // linux中,超过了2047有限制,需要设置内核参数
struct pollfd fds[maxfd + 1]; 

// 2、初始化fds所有位置为-1,表示忽略该元素,poll在查找事件时就不会遍历这个元素
for (int = 0; i <= maxfd; i++)
    fds[i].fd = -1;

// 3、将服务端套接字放到fds第一个位置,并设置监听POLLRDNORM事件
fds[0].fd = listensock;
fds[0].events = POLLIN;  // 读事件

// 4、循环里调用poll函数
int infds = poll(fds, maxfd + 1, 10); // 超时时间为10毫秒

/*
* 5、当poll返回值大于0
* 先判断fds[i].fd是否为-1,若不为,再通过fds[eventfd].revents&POLLIN
* 判断某个描述符是否有读事件
*/

/*
* 如果是listensock
* 接受新的客户端连接,将新套接字
* 找个-1的空位置存放起来,并监听POLLRDNORM事件
*/

/*
* 如果是原先存在的客户端事件,则做相应的业务处理:
* 读取数据成功,继续相应的业务处理操作
* 读取失败,将该位置设置为-1,fds[i].fd = -1;
* 且关闭套接字,close(fds[i].fd)
*/

与select异同

相同点:poll和select都会遍历所有描述符,在连接数非常大时有性能问题,而epoll就很好的解决该个问题。

不同点:

1、select采用fd_set和bitmap,而poll采用数组;

2、在声明pollfd结构数据的时候,可以自行指定大小,linux超过1024需要设置内核参数;

3、select会修改fd_set,因此需要复制一份。poll不会修改pollfd,它通过pollfd的events指定要监听的事件,再通过revents保存已发生事件用于在poll返回时判断都有哪些事件发生。poll用两个short整型来保存监听事件,和已经发生的事件。意味着,调用poll之前不需要将监听的事件复制一份。I/O设置监听事件使用events,判断是否有事件发生使用revents。

缺点

与select类似,poll文件描述符数组被整体复制于用户态和内核态的地址空间之间,不论这些文件描述符是否有事件,开销随着文件描述符数量增加而线性增大。poll返回后,也需要历遍整个描述符数组才能得到有事件的描述符。

完整服务程序server_poll.c

编译:gcc -o server server_select.c

运行:./server 5555

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/poll.h>
#include <netinet/in.h>
#include <string.h>

int main(int argc, char *args[]) {
	if (argc != 1 + 1) {	// 接受端口参数
		printf("please give port!\n");
		return 0;
	}

	// 1、创建套接字
	int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

	// 2、设置端口复用
	int opt = 1;
	unsigned int len = sizeof(opt);
	setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, len);

	// 3、设置服务端的IP和端口
	struct sockaddr_in server_addr;
	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	server_addr.sin_port = htons(atoi(args[1]));

	// 4、将套接字和IP、端口绑定
	bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

	// 5、开始监听
	listen(listen_fd, 50);

	// 6、声明和初始化struct pollfd类型数组
	int maxfd = 1023;
	struct pollfd fds[maxfd + 1];
	int i = 0;
	for(;i < maxfd + 1;i ++) {
		fds[i].fd = -1;
	}

	// 7、将listen_fd放入首位置并监听读事件
	fds[0].fd = listen_fd;
	fds[0].events = POLLIN;

	while (1) {
		int num = poll(fds, maxfd + 1, 50);	// 等待50ms
		if (num == -1) {	// 出错
			printf("poll error\n");
			close(listen_fd);
			break;
		} else if (num == 0) {	// 没有事件
			continue;
		} else {
			// 先判断是否有新的客户端连接进来
			if(fds[0].revents & POLLIN) {
				struct sockaddr_in client_addr;
				socklen_t len = sizeof(client_addr);
				int connet_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
				printf("client [%d] is connected!\n", connet_fd);
				int i = 1;
				for(;i < maxfd + 1;i ++) {		// 找个空位置把新的客户端监听起来
					if (fds[i].fd == -1) {
						fds[i].fd = connet_fd;
						fds[i].events = POLLIN;
						break;
					}
				}
			}

			// 判断其他的df是否有事件
			int i = 1;
			for(;i < maxfd + 1;i ++) {
				if(fds[i].fd == -1) {
					continue;
				}
				if(fds[i].revents & POLLIN) {
					char buffer[1024];
					memset(buffer, 0, sizeof(buffer));
					int res = recv(fds[i].fd, buffer, sizeof(buffer), 0);
					if(res == 0) {
						close(fds[i].fd);
						printf("client [%d] is disconnected!\n", fds[i].fd);
						fds[i].fd = -1;
					} else {
						printf("recv data from client [%d] is %s\n", fds[i].fd, buffer);
						send(fds[i].fd, buffer, sizeof(buffer), 0);
					}
				}
			}

		}
	}

	return 0;
}

3、epoll

原理

epoll原理比select、poll复杂得多,后面单独用一篇文章介绍。

用法参数及使用流程

// 1、创建epoll实例
int epoll_create(int size);
int epoll_create1(int flags);
// 参数:一般传0即可
// 返回值:大于0表示epoll实例,-1表示出错

// 2、注册要监听的fd和事件
int epoll_ctl(
  int epfd,
  int op,
  int fd,
  struct epoll_event *event
);
/*
* 参数:
* epfd:使用poll_create创建出的epoll实例
* op:表示增、删、改分别对应:
  EPOLL_CTL_ADD:向epoll实例注册文件描述符对应的事件;
  EPOLL_CTL_DEL:删除epoll实例中文件描述符对应的事件;
  EPOLL_CTL_MOD:修改epoll实例中文件描述符对应的事件。
* fd:要注册事件的描述符,这里指网络套接字。
* event:是一个结构体,如下:
  struct epoll_event {
      uint32_t events;   // epoll事件
      epoll_data_t data;
  };
  - typedef union epoll_data {
      void *ptr;
      int fd;
      uint32_t u32;
      uint64_t u64;
    } epoll_data_t;
  对于events一般设置如下:
  这里的事件与poll的基本一样
  下面是在使用epoll的时候,常用的事件类型:
  EPOLLIN:表示描述符可读
  EPOLLOUT:表示描述符可写
  EPOLLRDHUP:表示描述符一端已经关闭或者半关闭
  EPOLLHUP:表示对应描述符被挂起
  EPOLLET:边缘触发模式edge-triggered,不设置默认使用
*/

/*
* epoll_ctl返回值:
* 0表示成功
* -1表示出错
*/

// 3、等待事件发生
int epoll_wait(
  int epfd,
  struct epoll_event *events,
  int maxevents,
  int timeout
);
/*
* 参数:
* epfd:使用poll_create创建出的epoll实例
* events:要处理的I/O事件,是个数组,大小是epoll_wait的返回值,每一个元素是一个待处理的I/O事件。
* maxevents:epoll_wait可以返回的最大事件
* timeout:超时时间,和select基本是一致的。
  - 如果设置-1表示不超时;
  - 设置0表示立即返回;
*/

/*
* epoll_wait返回值:
  大于0表示事件个数;
  0表示超时;
  -1表示出错。
*/

与poll区别

1、epoll需要使用poll_create创建一个实例,后续所的操作都基于这个实例。

2、epoll不再是将fd设置成-1来表示忽略当前描述,而是关心哪个就设置哪个,使用epoll_ctl函数。

// 例如:
struct epoll_event event;
event.data.fd = sock_fd;
event.events = EPOLLIN | EPOLLET;
epoll_ctl(efd, EPOLL_CTL_ADD, sock_fd, &event)

3、events返回所有实际产生事件集合,大小就是epoll_wait返回值。所以,epoll_wait返回,就可以确定从0到read_num所有位置都是有事件发生。而poll每次都从0遍历到最大描述字。这中间有很多没有事件发生的描述符。这种实现绕不开它背后的数据结构红黑树。

触发方式

边沿触发:只在第一次有数据可读的情况下通知一次。后面的处理就完全靠自己了,很显然这种触发方式能够明显减少触发次数,从而减轻内核的压力,这在一些大数据量的传输场景下非常有用。

水平|条件触发(epoll默认):每次有数据可读时都会触发事件,某些情况下会造成内核频发触发事件。

完整服务程序server_epoll.c

编译:gcc -o server server_epoll.c

运行:./server 5555

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <string.h>
#include <stdlib.h>

int main(int argc, char *args[]) {
	if (argc != 1 + 1) {
		printf("please give port!\n");
		return 0;
	}

	// 1、创建套接字
	int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

	// 2、设置端口复用
	int opt = 1;
	unsigned int len = sizeof(opt);
	setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, len);

	// 3、设置服务端的IP和端口
	struct sockaddr_in server_addr;
	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	server_addr.sin_port = htons(atoi(args[1]));

	// 4、将套接字和IP、端口绑定
	bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));

	// 5、开始监听
	listen(listen_fd, 50);

	// 6、创建epoll实例
	int epfd = epoll_create1(0);

	// 7、注册listen_fd和事件
	struct epoll_event event;
	event.data.fd = listen_fd;
	event.events = EPOLLIN | EPOLLET;  // 可读事件 | 边缘触发
	epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &event);

	// 8、申请epoll_event数组
	int maxfd = 1024;
	struct epoll_event events[maxfd];
	
	while (1) {
		// 9、循环中调用epoll_wait来等待事件发生
		int num = epoll_wait(epfd, events, maxfd, 20);
		int i = 0;
		for (;i < num;i ++) {
			if (listen_fd == events[i].data.fd) {  // listen的socket有事件
				struct sockaddr_in client_addr;
				socklen_t len = sizeof(client_addr);
				int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
				if (conn_fd == -1) {
					perror("accept error.");
					continue;
				} else {
					printf("client [%d] is connected !\n", conn_fd);
					struct epoll_event event_tmp;
					event_tmp.data.fd = conn_fd;
					event_tmp.events = EPOLLIN | EPOLLET;
					epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &event_tmp);
				}
			} else {	// 已经连接的客户端socket有事件
		        int client_fd = events[i].data.fd;
				char buffer[1024];
				memset(buffer, 0, sizeof(buffer));
				int res = recv(client_fd, buffer, sizeof(buffer), 0); 
				if (res == 0) {
					close(client_fd);
					printf("client [%d] is disconnected !\n", client_fd);
				} else {
					printf("recv data from client [%d] is %s\n", client_fd, buffer);
					send(client_fd, buffer, sizeof(buffer), 0);
		        }
	        }
		}
	}

	return 0;
}

六、几种IO模型对比

1、一请求一线程

对应上面的完整代码server_base.c

特点:在一个while循环中,一直"accept"客户端的连接。来一个客户端,就为其分配一个线程,去处理请求。 每个线程里面,也是while循环不断处理每个客户端的请求。

优点:逻辑简单。

缺点:不适合大量的客户端请求,无法突破C10K。(client 10 k量级的连接)

2、select模型

特点:maxfd有最大限制,1024。通过多设置几个select,相比方法1,能突破C10K,但是难以突破C1000K,因为每次调用fd_set需要copy进内核,然后返回再copy出来,涉及系统调用,当大量copy时,还是有限制的。

3、poll模型

特点:与select差不多,都是采用轮询,比较消耗资源,只不过maxfd没有了1024的限制。如果超过1024可能需要设置一下内核参数。

4、epoll模式

特点:不再是将fd设置成-1来表示忽略当前描述f符,而是关心哪个就设置哪个。events返回所有实际产生事件集合,大小就是epoll_wait返回值。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值