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

2024-08-17 21:56:22 浏览数 (3)

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

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

1.1、建立连接

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

1.1.1 接收连接

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

代码语言:javascript复制
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);

简单示例:

代码语言:javascript复制
#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函数原型:

代码语言:javascript复制
#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时,超时可能很长。

简单示例:

代码语言:javascript复制
#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函数原型:

代码语言:javascript复制
#include <unistd.h>

int close(int fd);

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

错误码

含义

EBADF

fd不是有效的打开文件描述符。

EINTR

close()调用被信号中断

EIO

发生I/O错误。

shutdown函数原型:

代码语言:javascript复制
#include<sys/socket.h>

int shutdown(int fd,int flag);

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

参数

含义

SHUT_RDWR

值为2,表示关闭读写段

SHUT_WR

值为1,表示关闭本地写段,对端读段

SHUT_RD

值为0,表示关闭本地读段,对端写段

使用方式:

代码语言:javascript复制
//主动关闭
close(fd);
shoutdown(fd,SHUT_RDWR);

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

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

1.2.1 被动断开

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

代码语言:javascript复制
/*......*/
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函数原型:

代码语言:javascript复制
#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函数。从缓冲区中读取数据。

代码语言:javascript复制
//......

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函数。往写缓冲区写数据。

代码语言:javascript复制
//......

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数据就返回写成功,而不是写完数据才返回成功。 原理图如下:

2.1.2 非阻塞模式

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

代码语言:javascript复制
//......

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

//......

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

2.1.3 两者区别

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

2.2 非阻塞IO处理方式

2.2.1 建立连接

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

2.2.1.1 主动连接

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

代码语言:javascript复制
#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,表示获取到一个已完成连接的客户端。 示例:

代码语言:javascript复制
#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模型:

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

代码语言:javascript复制
#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功能。 简单示例:

代码语言:javascript复制
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 : %sn", strerror(errno));
        return -1;
    }

    printf("listen server port : %dn", 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的三次握手和四次挥手过程。

0 人点赞