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

简介: 本文深入剖析了Linux网络设计中网络IO的重要角色。网络IO在Linux系统中扮演着关键的角色,负责管理和协调数据在网络中的传输。我们将探讨网络IO的基本概念、作用和实现原理。首先介绍了Linux网络IO的核心组件,如套接字、文件描述符和缓冲区,以及它们在网络通信中的作用。然后详细解释了常见的网络IO模型,包括阻塞IO、非阻塞IO、多路复用IO和异步IO,并比较它们的特点和适用场景。紧接着,我们深入研究了Linux内核中网络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

相关实践学习
CentOS 7迁移Anolis OS 7
龙蜥操作系统Anolis OS的体验。Anolis OS 7生态上和依赖管理上保持跟CentOS 7.x兼容,一键式迁移脚本centos2anolis.py。本文为您介绍如何通过AOMS迁移工具实现CentOS 7.x到Anolis OS 7的迁移。
目录
相关文章
|
3天前
|
域名解析 网络协议 安全
|
9天前
|
运维 监控 网络协议
|
22天前
|
网络协议 前端开发 Java
网络协议与IO模型
网络协议与IO模型
网络协议与IO模型
|
3天前
|
网络协议 物联网 API
Python网络编程:Twisted框架的异步IO处理与实战
【10月更文挑战第26天】Python 是一门功能强大且易于学习的编程语言,Twisted 框架以其事件驱动和异步IO处理能力,在网络编程领域独树一帜。本文深入探讨 Twisted 的异步IO机制,并通过实战示例展示其强大功能。示例包括创建简单HTTP服务器,展示如何高效处理大量并发连接。
17 1
|
4天前
|
存储 关系型数据库 MySQL
查询服务器CPU、内存、磁盘、网络IO、队列、数据库占用空间等等信息
查询服务器CPU、内存、磁盘、网络IO、队列、数据库占用空间等等信息
90 1
|
4天前
|
存储 Ubuntu Linux
2024全网最全面及最新且最为详细的网络安全技巧 (三) 之 linux提权各类技巧 上集
在本节实验中,我们学习了 Linux 系统登录认证的过程,文件的意义,并通过做实验的方式对 Linux 系统 passwd 文件提权方法有了深入的理解。祝你在接下来的技巧课程中学习愉快,学有所获~和文件是 Linux 系统登录认证的关键文件,如果系统运维人员对shadow或shadow文件的内容或权限配置有误,则可以被利用来进行系统提权。上一章中,我们已经学习了文件的提权方法, 在本章节中,我们将学习如何利用来完成系统提权。在本节实验中,我们学习了。
|
12天前
|
Ubuntu Linux 虚拟化
Linux虚拟机网络配置
【10月更文挑战第25天】在 Linux 虚拟机中,网络配置是实现虚拟机与外部网络通信的关键步骤。本文介绍了四种常见的网络配置方式:桥接模式、NAT 模式、仅主机模式和自定义网络模式,每种模式都详细说明了其原理和配置步骤。通过这些配置,用户可以根据实际需求选择合适的网络模式,确保虚拟机能够顺利地进行网络通信。
|
24天前
|
开发者
什么是面向网络的IO模型?
【10月更文挑战第6天】什么是面向网络的IO模型?
20 3
|
24天前
|
数据挖掘 开发者
网络IO模型
【10月更文挑战第6天】网络IO模型
35 3
|
23天前
|
缓存 Java Linux
硬核图解网络IO模型!
硬核图解网络IO模型!

热门文章

最新文章