socket 异步通讯与状态机 | 掘金技术征文

2,062 阅读9分钟

前言:

刚与负责招聘的同事聊了聊,在网络socket编程中,应聘者常常没有异步通讯的经验,客户端发完数据就阻塞等待,或者开启一个进程或线程来专门处理 通讯问题。面试者很少有人能写出一个异步通讯的优雅流程,从而适应当前大规模平行计算的要求。我想就这个问题以FTP为例和大家分享一下。

问题:

FTP 是一个建立在TCP/IP之上,在客户端和服务器之间用于文件传输的标准协议。要实现一个完整的客户端文件下载过程,一般有三个步骤:

  1. 通过三次握手建立TCP的控制信道,然后发送用户名和密码,通过用户认证;还需要指定下载文件的模式和接收建立数据信道传输的必要参数。
  2. 在客户端和服务器之间建立一个数据信道。然后通过控制信道发送文件操作命令(上传或者下载)和文件名,再通过数据信道传输文件数据。
  3. 文件传输结束后,关闭数据信道。并且通过控制信道通知客户端文件传输结束。这时客户端可以关闭控制信道,结束FTP

在这个三个步骤中,第一步就足以包括异步通讯的所有机制。基于篇幅,本文将着重分析这一步。这一步涉及到很多TCP的交互操作。由于TCP是基于流的工作方式,一次系统调用不一定把数据或者指令发送完毕,这就需要等待对方有能力接收数据后,再次发送或者接收。另外,即使客户端发送完某个指令后,也必须等待服务器的回答才能继续下面的工作。从面试结果来看,大部分面试者都用了阻塞等待的方式。也就是直接调用socket的send或者recv命令发送或者接收数据;在等待服务器回答时,用select系统调用,直到超时。这样的实现会引起如下问题:

  1. 阻塞等待的过程中,这个进程没办法作别的工作,尤其是定时器更新等任务。一旦socket传输结束,就有可能引发超时异常。即使没有超时,CPU也会因为阻塞引起重新调度,从而CPU的利用率会大大降低。
  2. 为了避免阻塞原进程,有的面试者会开启一个专门通讯进程来处理通讯。但是进程的代价比较高,如果客户端要同时和数万个服务器连接,这样客户端很快会拓机。如果用线程来处理,虽然可以降低代价,但也无法避免。再就是如果新的线程需要对原进程的某个数据结构进行读写,这样可能会需要加锁,程序性能会变差,也增加维护的难度。

在上述两个问题中,如果面试者能够用进程来避免阻塞,一般可以认为是一个有潜力的面试者。如果能过有人用线程来处理,并且用锁或者其他机制来进行数据同步的话,可以考虑录取。但是我们还是希望能看到面试者写一个真正的异步通讯。

异步解决方案:

异步解决方案本质上是基于事件的驱动。为了详细解释这个机制,本文将先用时序图描述具体问题,然后用状态机来进行功能设计,最后给出伪代码。

时序图:

需要先用时序图来描述FTP第一步的通讯过程,这是下面两步的基础。

FTP控制信道的建立

状态机:

从时序图可以看出,FTP的客户端和服务器有多次交互。异步通讯,就是一个指令发送完成后,在收到回应之前,可以让这个进程能去作别的工作。等收到回应后,再从发送完成的那个代码点继续运行。要实现这个机制,我们可以用状态来记住那些代码点,这样自然就可以想到用有限状态机来进行设计。如何定义FTP的状态机呢?严格的说,每一次系统调用如果需要根据返回的结果才能决定下一步的动作的话,就需要定义一个状态。根据时序图建立的FTP客户端状态机如下三个图所示:

FTP控制信道状态机1
FTP控制信道状态机1
FTP控制信道状态机2
FTP控制信道状态机2
FTP控制信道状态机3
FTP控制信道状态机3

从状态机图可以看出,为了支持三次握手,FTP控制信道状态机1定义了Connect, Connecting, Connected三种状态,其中Connect是socket系统调用connect()之前,Connecting是connect()之后的状态。Connected是socket信道可用时的状态。这三个状态一般是顺序变化的,如果有了错误或者超时,都会回到结束状态,以便重新开始。接下来的每个状态又分为三种情况,分别是成功,进入下状态;失败或者超时,进入结束状态;还没有发送或接收完毕,保持本状态不变。这里需要指出的是,虽然保持本状态不变,进程还是可以去作其他任务,当底层socket准备好可以继续发送或者接收时,状态机会从当前状态继续运行。

伪代码:

为了说明状态机的实现,下面给出Linux下利用epoll实现的伪代码,在其他平台上,系统调用可能有所变化,但是思想不变。

1. 数据结构

  • Session: 记录每一次FTP会话,中间需要的所有运行数据和统计信息,还可以有相关的配置信息,如用户名,密码等。代码如下:
    struct session {
    struct channel channel;
    struct cfg {
        char servername[64];
        char username[32];
        char password[32];
        char mode;
        ....
    }
    uint32_t resolved_server_ip;
    uint64_t num_errors;
    uint32_t error_code;
    }
    
  • Channel: 控制信道所需要的数据缓存,状态机的状态,epoll和socket的描述符等。代码如下:
    struct channel {
        struct session *session;
        int rd_off, rd_len;
        char rd_buf[BUFF_SZ];
        int wr_off, wr_len;
        char wr_buf[BUFF_SZ];
        int state;
        struct epoll_handle {
            int fd;
            uint32_t event;
            int (*read)(struct epoll_handle *);
            int (*write)(struct epool_handle *);
            void *data;
        }
    }
    

2. 状态机

2.1 框架

```c
static void
ftp_channel_stmch_do_update(struct channel *channel, void *msg)
{
    int old_state = channel->state;
    int new_state = old_state;

    switch(old_state) {
    case CONNECT:
        new_state = process_tcp_connect(channel, msg);
        break;
    case statex..
        new_state = process_statex(channel, msg);
        break;
    ...
    }
    channel->state = new_state;
    return (new_state != old_state);
}
static void
ftp_channel_stmch_update(struct channel *channel, void *msg)
{
    while (ftp_channel_stmch_do_update(session, msg));
}
```

ftp_channel_stmch_do_update是状态机的执行部件。每次先将老状态储存起来,然后执行process_statex函数得到新状态。如果新老状态相等,则返回0,状态机退出,否则通过ftp_channel_stmch_update的while循环一直执行下去,直到新老状态相同,表示状态机稳定,可以退出。

2.2 发送和接收

这里发送和接收数据,为了避免进程阻塞,需要先将socket设成非阻塞方式。

```c
fcntl(fd, F_SETFL, old_flags | | O_NONBLOCK);
```

然后将channel的epoll与socket关联,并将epoll的event设置成EPOLLOUT。当TCP三次握手成功后,EPOLLOUT event会触发,在EPOLLOUT回调函数里面,可以将channel状态设为Connected, 同时可以发送指令。如果状态发生变化,需要执行状态机。EPOLLOUT的 回调函数伪代码如下:

```c
static int ftp_ctrl_epollout(struct epoll_handle *handle)
{
    struct channel *channel = (void *)handle->data;
    if (ftp_epollout_error_check(handle) < 0) {
            channel->state = FTP_STATE_ERROR;
            ftp_channel_stmch_update(channel);
            return -1;
    }

    if (ftp_ctrl_do_epollout(channel))
        lnkmt_ftp_stmch_update(channel);
    return 1;
}

static int ftp_ctrl_do_epollout(struct channel *channel)
{
    int old_state = channel->state;
    int new_state = old_state;
    if (!ftp_msg_send(channel))
            return 0;
    switch(old_state) {
    case statex:
            new_state = ...;
            break;
    case ...
    }
    channel->state = new_state;
    return (new_state != old_state);
}

static int ftp_msg_send(lnkmt_ftp_epoll_channel_t *channel)
{
    int ret;
    int fd = channel->ep.fd;
    if (channel->wr_off == channle->wr_len)
            return 1;
    ret = write(fd, channel->wr_data + channel->wr_off,
                    channel->wr_len - channel->wr_off);
    if (ret < 0) {
            if (errno == EAGAIN || errno == EINTR)
                    return 0;
            else {
                    channel->sate = FTP_STATE_ERROR;
                    return 1;
            }
    } else if (ret == 0) {
            channel->state = FTP_STATE_CLOSE;
            return 1;
    }
    channel->wr_off += ret;
    if (channel->wr_off < channel->wr_len)
            return 0;
    return 1;
```

这里要讲是ftp_msg_send函数,如果信道里面还有数据需要发送或者socket返回EINTR或EAGAIN, 则返回0。表示状态不变,状态机也不需要启动。系统下次将再出发EPOLLOUT event, 从而执行回调函数。

当socket收到服务器发过来的数据时,会触发EPOLLIN event, 我们可以设计类似的回调函数,这里就不再赘述。

2.3 超时和出错:

当发送和接收过程中,操作系统底层可能出错,这里需要在EPOLLOUT和EPOLLIN里面加入判断。如果socket出错,可以立即触发状态机,处理出错状态。判断出错的函数如下:

```c
getsockopt(fd, SOL_SOCKET, SO_ERROR, &ret, &len)
```

当TCP通讯时,如果中间设备断了,双方收不到数据,但也收不到close的信号,使得双法的TCP连接会保持相当长一段时间,这对资源是很大的浪费。这里可以利用Keep Alive或者Timer机制, 一旦超时,马上触发对应的回调函数,调用状态机,处理出错状态。回调函数伪代码如下:

```c
static void ftp_channel_timeout(unsigned long arg)
{       
    struct channel *channel = (void *)arg;
    
    channel->state = FTP_STATE_ERROR;
    lnkmt_ftp_stmch_update(channel->ppf);
}
```

性能分析

通过异步通讯方式,一个FTP会话的所有数据都包含在一个session里面。当存在成千上万个FTP会话时,可以利用一个session数组。每个sesion用一个ID来区分,还可用hash来实现快速定位。session之间没有公共数据,不需要加锁。因为这都是在一个进程里面,也没有进程切换,可以适应大规模的网络要求。从程序设计的角度来说,虽然状态很多,看起来有些复杂。实际上很多状态的处理方式都是一样的,实现和调试起来反而更简单,而且状态扩充也很容易。这就是一个优雅程序的框架。

总结

FTP 的另外两个步骤,异步通讯需要定义两个状态机:控制通道状态机和数据通道状态机,还需要考虑这两个状态机的同步问题。篇幅所限,就不再深入。如果有读者感兴趣,可以考虑再写一篇续,谈谈复杂状态机的同步问题。总之,网络发展到现在的阶段,大规模,分布式,并行等都是通讯的基本要求。如果只是网络的两个实体通讯,通过一些简单的socket调用就可以实现。但是如果是上万个或者上十万个网络实体通讯,就从量变上升到了质变,考虑的问题和思路将有很大变化。希望大家面试顺利。