深入剖析Linux网络设计中网络IO的重要角色

举报
Lion Long 发表于 2023/07/18 23:33:30 2023/07/18
【摘要】 本文深入剖析了Linux网络设计中网络IO的重要角色。网络IO在Linux系统中扮演着关键的角色,负责管理和协调数据在网络中的传输。我们将探讨网络IO的基本概念、作用和实现原理。首先介绍了Linux网络IO的核心组件,如套接字、文件描述符和缓冲区,以及它们在网络通信中的作用。然后详细解释了常见的网络IO模型,包括阻塞IO、非阻塞IO、多路复用IO和异步IO,并比较它们的特点和适用场景。

一、网络编程关注的四个方面

网络编程主要关注四个问题:连接的建立、断开连接、消息到达、消息发送。
不管使用什么样的网络模型,不管使用的是阻塞IO还是非阻塞IO,不管是同步IO还是异步IO,都需要关注这四个问题。

1.1、建立连接

连接有两种:服务器处理接收客户端的连接;服务器作为客户端主动连接第三方服务。

1.1.1 接收连接

接收连接主要使用accept()函数,用于从全连接队列中返回一个已完成的连接。如果成功,返回值大于0表示与一个客户端TCP建立了连接;返回值是由kernel自动生成的一个全新描述符。在非阻塞模式下,accept()返回-1表示全连接队列中没有已完成的客户端接入。
accept函数原型:

ACCEPT(2)                  Linux Programmer's Manual                 ACCEPT(2)
NAME
       accept, accept4 - accept a connection on a socket
SYNOPSIS
       #include <sys/types.h>          /* See NOTES */
       #include <sys/socket.h>
       int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

简单示例:

#include <stdio.h>
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>

#define LISTEN_PORT	8888

int main()
{

	int listenfd=socket(AF_INET,SOCK_STREAM,0);
	
	struct sockadd_in serveraddr;
	memset(&serveraddr,0,sizeof(serveraddr));
	serveraddr.sin_family=AF_INET;
	serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
	serveraddr.sin_port=htons(LISTEN_PORT);
	
	bind(listenfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr));
	
	while(1)
	{
		struct sockaddr_in clientaddr;
		socklen_t len=sizeof(clientaddr);
		clientfd=accept(listenfd,&clientaddr,&len);
		
		/*......
		* 处理逻辑代码
		*/
	}
	return 0;
}

1.1.2 主动连接

主动连接由connect()函数发起,主动连接服务器。成功返回0;失败则返回-1,并设置了全局变量errno,应该处理connect函数返回的错误码。

connect函数原型:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

/*
* sockfd:socket文件描述符
* addr:指定服务器端地址信息,包括IP地址和端口。
* addrlen:指定地址信息的大小
*/

connect()和bind()参数形式一样,区别在于bind()参数的地址信息是自己的,connect()参数的地址信息是对方的地址信息。
失败时返回的错误码:

错误码 含义
EACCES,EPERM 用户在未启用套接字广播标志的情况下尝试连接到广播地址,或者由于本地防火墙规则,连接请求失败。
EADDRINUSE 本地地址已在使用中。
EADDRNOTAVAIL 套接字未绑定到地址,在尝试将其绑定到临时端口时,确定临时端口范围内的所有端口号当前都在使用中。
EAFNOSUPPORT 传递的地址在其sa_family字段中没有正确的地址族。
EAGAIN 路由缓存中的条目不足。
EALREADY 套接字是非阻塞的,以前的连接尝试尚未完成。
EBADF 文件描述符不是描述符表中的有效索引。
EconRefuse 没有人监听远程地址。
EFAULT 套接字结构地址在用户的地址空间之外。
EINPROGRESS 套接字是非阻塞的,无法立即完成连接。
EINTR 系统调用被捕获的信号中断;参见信号(7)。
EISCONN 套接字已连接。
ENETUNREACH 网络无法访问。
ENOTSOCK 文件描述符sockfd不引用套接字。
EPROTOTYPE 套接字类型不支持请求的通信协议。例如,在尝试将UNIX域数据报套接字连接到流套接字时,可能会发生此错误。
ETIMEDOUT 尝试连接时超时。服务器可能太忙,无法接受新连接。注意,对于IP套接字,当服务器上启用Syncookie时,超时可能很长。

简单示例:

#include <stdio.h>
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>

#define LISTEN_PORT 8888

int main()
{
	int connectfd=socket(AF_INET,SOCK_STREAM,0);
	
	struct sockaddr_in serveraddr;
	memset(&serveraddr,0,sizeof(serveraddr));
	serveraddr.sin_family=AF_INET;
	serveraddr.sin_addr.s_addr=htonl("127.0.0.1");//服务器IP
	serveraddr.sin_port=htons(LISTEN_PORT);//服务器端口
	
	int ret = connect(connectfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr));
	if(ret ==1)
	{
		// ret == -1 && errno == EINPROGRESS 正在建立连接
		// ret == -1 && errno = EISCONN 连接建立成功
		switch(errno)
		{
		/*处理错误码*/
		
		}
	}
	
	/*处理逻辑*/
	
}

1.2 断开连接

断开分两种,主动断开和被动断开。

1.2.1 主动断开

主动断开主要调用close()函数。有些网络编程需要支持半关闭状态时,使用shutdown()函数。
close函数原型:

#include <unistd.h>

int close(int fd);

close()关闭文件描述符,使其不再引用任何文件,并可重复使用。成功返回0;失败则返回-1,并设置了全局变量errno。
失败错误码:

错误码 含义
EBADF fd不是有效的打开文件描述符。
EINTR close()调用被信号中断
EIO 发生I/O错误。

shutdown函数原型:

#include<sys/socket.h>

int shutdown(int fd,int flag);

成功则返回0, 失败返回-1, 错误码放在errno。
flag参数说明:

参数 含义
SHUT_RDWR 值为2,表示关闭读写段
SHUT_WR 值为1,表示关闭本地写段,对端读段
SHUT_RD 值为0,表示关闭本地读段,对端写段

使用方式:

//主动关闭
close(fd);
shoutdown(fd,SHUT_RDWR);

// 主动关闭本地读端,关闭对方写端
shutdown(fd,SHUT_RD);

// 主动关闭本地写端,关闭对方读端
shutdown(fd,SHUT_WR);

1.2.1 被动断开

主要依据recv/read、send/write判断。有的网络编程需要支持半关闭状态。

/*......*/
char buffer[1024]={ 0 };

// 被动,读端被关闭
int ret=recv(fd,buffer,1024,0);
if(ret==0)
{
	close(fd);
}

/*......*/

//被动,写端关闭
ret =send(fd,buffer,1024,0);
if(ret==0 && errno == EPIPE)
{
	close(fd);
}

/*......*/

recv和send函数原型:

#include <sys/types.h>
#include <sys/socket.h>

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

ssize_t send(int sockfd, void *buf, size_t len, int flags);

成功返回接收 / 发送的字节数;失败则返回-1,并设置errno以指示错误。
注意,recv也可能返回0。当流套接字对等端执行有序关闭时,返回值将为0;不同域(例如UNIX和Internet域)中的数据报套接字允许零长度数据报,当接收到这样的数据报时,返回值为0;如果从流套接字接收的请求字节数为0,则也可以返回值0。
recv的错误码:

错误码 含义
EAGAIN,EWOULDBLOCK 套接字标记为非阻塞,接收操作要求阻塞,或者设置了接收超时,并且在接收数据之前超时。
EBADF 参数sockfd是无效的描述符。
ECONREFUSED 远程主机拒绝允许网络连接(通常是因为它没有运行请求的服务)。
EFAULT 接收缓冲区指针指向进程地址空间之外。
EINTR 在任何数据可用之前,发送信号中断了接收。
EINVAL 传递的参数无效。
ENOMEM 无法为recvmsg()分配内存。
ENOTCONN 套接字与面向连接的协议关联,尚未连接。
ENOTSOCK 文件描述符sockfd不引用套接字。

send错误码:

错误码 含义
EACCES 对目标套接字文件的写入权限被拒绝,或者对路径前缀为的目录之一的搜索权限被拒绝。(对于UDP套接字)尝试发送到网络/广播地址,好像它是单播地址一样。
EAGAIN,EWOULDBLOCK 套接字标记为非阻塞,请求的操作要求阻塞。
EAGAIN sockfd引用的套接字以前未绑定到地址,在尝试将其绑定到临时端口时,确定临时端口范围内的所有端口号当前都在使用中。
EBADF 指定的描述符无效。
EconReset 对等端重置连接。
EDESTADDRREQ 套接字不是连接模式,并且未设置对等地址。
EFAULT 为参数指定了无效的用户空间地址。
EINTR 在传输任何数据之前发生的信号。
EINVAL 传递的参数无效。
EISCONN 连接模式套接字已连接,但指定了收件人。(现在要么返回此错误,要么忽略收件人规范。)
EMSGSIZE 套接字类型要求以原子方式发送消息,而要发送的消息的大小使得这不可能。
ENOBUFS 网络接口的输出队列已满。这通常表示接口已停止发送,但可能是由瞬时拥塞造成的。(通常情况下,在Linux中不会发生这种情况。当设备队列溢出时,数据包会自动丢弃。)
ENOMEM 没有可用内存。
ENOTCONN 未连接套接字,且未指定目标。
ENOTSOCK 文件描述符sockfd不引用套接字。
EOPNOTSUPP flags参数中的某些位不适用于套接字类型。
EPIPE 本地端已在面向连接的套接字上关闭。在这种情况下,进程也将接收一个SIGPIPE,除非设置了MSG_NOSIGNAL。

1.3 消息到达

接收消息使用recv / read函数。从缓冲区中读取数据。

//......

while(1)
{
	//......
	
	char buffer[1024]={ 0 };
	int ret =recv(fd,buffer,1024,0);
	if(ret<0)// ret==-1
	{
		if(errno==EINTR || errno == EWOULDBLOCK)
			break;
		// 四次挥手发送ack之前,还可以发送数据
		// send(....)
		close(fd);
	}
	else if(ret==0)
		close(fd);
	else
	{
		//处理buffer
	}
	
	//......
}

//......

1.4 消息发送

发送消息使用send / write函数。往写缓冲区写数据。

//......

char buffer[1024]={ 0 };
//......

int ret = send(fd,buffer,1024,0);
if(ret==-1)
{
	if(errno==EINTR || errno == EWOULDBLOCK)
		return;
	close(fd);
}
//......

二、操作IO

只能使用IO函数进行操作,有两者操作方式:阻塞IO和非阻塞IO。

2.1 操作方式

2.1.1 阻塞模式

一般情况下,fd默认是阻塞的。阻塞模式会阻塞在网络线程。比如,当调用recv,读缓冲区没有数据时,则一直阻塞,直到有数据可读才返回。注意,send函数不是把数据写完了才返回,而是只要写缓冲区有空间给它write数据就返回写成功,而不是写完数据才返回成功。
原理图如下:
image.png

2.1.2 非阻塞模式

连接的fd的阻塞属性决定了IO函数是否阻塞。默认情况下fd是阻塞的,要设置非阻塞模式,可以使用一下方式:

//......

int flag = fcntl(fd,F_GETFL,0);
flag|=O_NONBLACK;
fcntl(fd,F_SETFL,flag);

//......

设置了非阻塞模式后,调用IO函数时,不管有没有成功都返回。比如,当调用recv,读缓冲区没有数据时,返回-1,并设置errno,errno应该是EWOULDBLOCK。
原理如下:
image.png

2.1.3 两者区别

从上面原理图可以看出,差异主要在数据准备阶段。具体差异在:IO函数在数据未就绪时是否立刻返回。

2.2 非阻塞IO处理方式

2.2.1 建立连接

连接有两种:服务器处理接收客户端的连接;服务器作为客户端主动连接第三方服务。

2.2.1.1 主动连接

当服务器需要连接第三方服务,需要调用connect函数进行连接。
在非阻塞IO中,connect()会一直返回-1,同时设置errno;需要检查errno是EINPROGRESS(正在建立连接)还是EISCONN(已经建立连接)。
示例:

#define SERVER_PORT	8888
//......

struct sockaddr_in serv;
memset(&serv,0,sizeof(serv));
serv.sin_family=AF_INET;
serv.sin_addr.s_addr=htonl("127.0.0.1");//要连接的服务器ip地址
serv.sin_port=htons(SERVER_PORT);
while(1)
{
	int ret = connect(fd,(struct sockaddr *)&serv,sizeof(serv));
	if(ret==-1 && errno==EISCONN)
	{
		// ........
		break;
	}
}
// ......

2.2.1.1 接收连接

服务器通过accept()函数从全连接队列中获得已完成连接的客户端,并返回内核自动生成的文件描述符。
在非阻塞模式中,完成socket()、bind()、listen()的调用后,会循环调用accept()函数,如果返回值大于0,表示获取到一个已完成连接的客户端。
示例:

#define SERVER_PORT	8888
//......
int listenfd=socket(AF_INET,SOCK_STREAM,0);

struct sockaddr_in serv;
memset(&serv,0,sizeof(serv));
serv.sin_family=AF_INET;
serv.sin_addr.s_addr=htonl(INADDR_ANY);
serv.sin_port=htons(SERVER_PORT);

bind(listenfd,(struct sockaddr *)&serv,sizeof(serv));

listen(listenfd,10);

//......
while(1)
{
	struct sockaddr_in clientaddr;
	socklen_t len=sizeof(clientaddr);
	int ret = accept(fd,(struct sockaddr *)&serv,sizeof(serv));
	if(ret>0)
	{
		

		// ........
		break;
	}
}
// ......

2.2.2 断开连接

如1.2所描述。

2.2.3 消息到达

在非阻塞模式中,如果读缓冲区没数据,recv/read函数返回-1,并且设置errno为EWOULDBLOCK。如1.3所描述。

2.2.4消息发送

如1.4所描述。

2.3 IO函数说明

IO函数既有检测IO功能也有操作IO功能。
例如:

IO函数 IO操作功能 IO检测功能
accept 从全连接队列中取出一个已完成连接的节点,并返回内核自动生成文件描述符以及客户端的ip地址和端口等信息 检测全连接队列中是否有已完成的连接的节点。
recv 从读缓冲区中读取数据到用户态 检测读缓冲区是否有数据
send 拷贝数据到写缓冲区 检测写缓冲区是否可写

注意,IO函数只能检测一条连接就绪的状态以及操作一条连接的IO数据

三、IO多路复用检测IO

IO多路复用不会操作IO,只检测IO的就绪状态。 但是IO多路复用可以检测多个IO的就绪状态。IO多路复用主要有:select、poll、epoll。IO多路复用只能检测比较笼统的事件(比如 读事件、写事件、错误事件),IO函数可以检测具体的事件。
IO多路复用检测IO模型:
image.png

以epoll为例,epoll主要有三个函数:epoll_create、epoll_wait、epoll_ctl。
epoll函数原型:

#include <sys/epoll.h>

/*相关数据结构*/
struct eventpoll {
    // ...
    struct rb_root rbr; // 红黑树,管理 epoll 监听的事件
    struct list_head rdllist; // 链表,保存着 epoll_wait返回满⾜条件的事件
    // ...
};
struct epitem {
    // ...
    struct rb_node rbn; // 红⿊树节点
    struct list_head rdllist; // 双向链表节点
    struct epoll_filefd ffd; // 事件句柄信息
    struct eventpoll *ep; // 指向所属的eventpoll对 象
    struct epoll_event event; // 注册的事件类型
    // ...
};
struct epoll_event {
    __uint32_t events; // epollin ,epollout ,epollel(边缘触发)
    epoll_data_t data; // 保存 关联数据
};
typedef union epoll_data {
	void *ptr;
	int fd;
	uint32_t u32;
	uint64_t u64;
}epoll_data_t;

/*相关接口*/

int epoll_create(int size);

/*
* op:
* 	EPOLL_CTL_ADD	添加事件
* 	EPOLL_CTL_MOD	修改事件
* 	EPOLL_CTL_DEL	删除事件
*
* event:
* 	EPOLLIN		注册读事件
* 	EPOLLOUT	注册写事件
* 	EPOLLET		注册边沿触发,默认是水平触发
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/*
* events[i].event:
* 	EPOLLIN		触发读事件
* 	EPOLLOUT	触发写事件
* 	EPOLLERR	触发错误事件
* 	EPOLLRDHUP	连接读端关闭
* 	EPOLLHUP	连接读写端关闭
*
* timeout:
* 	-1,体现阻塞特性,直到有事件触发才返回
* 	0,体现非阻塞特性,立刻返回
* 	>0,超时时间,最多等待timeout时间,如果还没有事件触发就返回;单位是ms。
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

调用epoll_create会创建一个epoll对象;
调用epoll_ctl添加到epoll中的事件都会与网卡驱动程序建立回调关系,相应事件触发时会调用触发函数(ep_poll_callback),将触发的事件拷贝到双向链表(rdllist)中;
调用epoll_wait会从双向链表中就绪事件拷贝到用户态中。

那么,IO多路复用是怎么检测IO事件的呢?以epoll为例。

3.1 建立连接

连接有两种方式:主动连接和接受连接。

3.1.1 主动连接

主动连接主要通过connect()函数建立。
首先,通过socket()函数创建一个socket对象;
然后,epoll(IO多路复用器)监听写事件,调用connect函数,在三次握手阶段,客户端向服务端发送ack(在第三次)的同时发送写就绪信号给epoll(IO多路复用器);
这就实现了epoll(IO多路复用器)检测到主动连接完成。

3.1.2 接受连接

接受连接主要通过socket()、bind()、listen()、accept()函数。
首先,通过socket()函数创建一个socket对象,bind()绑定地址,listen()监听端口,完成一个listenfd的创建和设置;
其次,epoll(IO多路复用器)监听listenfd的读事件,三次握手成功后全连接队列会产生一个节点,同时发送信号告诉epoll(IO多路复用器),触发读事件;这时说明连接完成。
然后,调用accept()函数,执行操作IO功能。
简单示例:

int init_sock(short port) {

	int fd = socket(AF_INET, SOCK_STREAM, 0);
	fcntl(fd, F_SETFL, O_NONBLOCK);

	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(port);

	bind(fd, (struct sockaddr*)&server_addr, sizeof(server_addr));

	if (listen(fd, 20) < 0) {
		printf("listen failed : %s\n", strerror(errno));
		return -1;
	}

	printf("listen server port : %d\n", port);
	return fd;
}
int main()
{
	int epfd=epoll_create(1);
	int listenfd=init_sock(8888);

	struct epoll_event ep_ev = {0, {0}};
	ep_ev.data.fd=listenfd;
	ep_ev.events=EPOLLIN;
	epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ep_ev);
	while(1)
	{
		struct epoll_event ep_ev_client[1024];
		int n = epoll_wait(epfd,ep_ev_client,1024,-1);
		int i=0;
		for(i=0;i<n;i++)
		{
			if(ep_ev_client[i].events & EPOLLIN)
			{
				//处理读事件......
			}
			if(ep_ev_client[i].events & EPOLLOUT)
			{
				//处理写事件......
			}
		}
	}
	return 0;
}

3.2 连接断开

IO多路复用器检测的是被动断开。
当epoll返回EPOLLRDHUP表示服务器读端关闭了;当epoll返回EPOLLHUP表示服务器读写端都关闭了。

3.3 消息到达

epoll(IO多路复用器)检测客户端fd的读事件。
当客户端发送数据到服务器的读缓冲区时,会发送信号给epoll(IO多路复用器),epoll(IO多路复用器)就会触发读事件,说明读缓冲区填充有数据;此时就可以调用recv/read函数操作IO。

3.4 消息发送

epoll(IO多路复用器)检测客户端fd的写事件。
当写缓冲区可写(即写缓冲区有空间可以写数据)时,它会发信号告诉epoll(IO多路复用器),epoll(IO多路复用器)触发写事件,这时调用send/write函数操作IO。

四、总结

一定要熟悉网络编程的四个关注点(建立连接、消息到达、消息发送、断开连接),深入理解操作IO和检测IO,这样才能很好的理解网络编程的源码,设计出高效的网络模型。
特别需要理解TCP的三次握手和四次挥手过程。
image.png
image.png

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。