Linux 网络编程 | 多连接 TCP 文件传输程序

这篇博客介绍在 Linux 下使用 epoll 和 Socket API 编写一个使用多条 TCP 连接来传输文件的 C/S 模式程序。

程序框架

对于这个程序,分为服务端和客户端。服务端负责发送文件,客户端负责接收文件。客户端首先向服务端请求文件元信息,如文件名、大小等,然后根据文件大小,同时建立多条的 TCP 连接进行分块下载,达到加速文件传输的目的。

完整的项目代码可以到 https://github.com/howardlau1999/tcp-file-transfer 获取。

Socket API 的使用

对于服务端而言,其最主要的功能是监听端口,因此封装一个 listen_port() 函数如下:

int listen_port(const char *PORT) {
    struct addrinfo hints, *servinfo, *p;

    const int BACKLOG = MAX_CONNS;
    int rv, fd;
    int yes = 1;
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = AI_PASSIVE;

    if ((rv = getaddrinfo(NULL, PORT, &hints, &servinfo)) != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
        return 1;
    }

    for (p = servinfo; p != NULL; p = p->ai_next) {
        if ((fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) == -1) {
            perror("server: socket error");
            continue;
        }

        if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1) {
            perror("server: setsockopt error");
            exit(1);
        }

        if (bind(fd, p->ai_addr, p->ai_addrlen) == -1) {
            close(fd);
            perror("server: bind error");
            continue;
        }

        break;
    }

    freeaddrinfo(servinfo);

    if (p == NULL) {
        fprintf(stderr, "server: failed to listen\n");
        exit(1);
    }

    if (listen(fd, BACKLOG) == -1) {
        close(fd);
        perror("server: listen error");
        exit(1);
    }

    return fd;
}

为了能优雅地支持 IPv4 和 IPv6,这里使用 getaddrinfo() 来获取可用的地址,调用之后会在 servinfo 中返回一个可用地址的链表,我们只需要依次尝试在这些地址上建立监听端口即可。

在尝试过程中,首先调用 socket() 获取一个套接字,然后设置 SO_REUSEADDR 方便快速重启,然后调用 bind() 尝试将套接字绑定到指定端口上,最后调用 listen() 函数监听套接字上的传入连接。

对于客户端而言,需要一个主动发起连接的函数 connect_to()

int connect_to(const char *host, const char *port) {
    struct addrinfo hints, *servinfo, *p;
    int rv, fd;
    char s[INET6_ADDRSTRLEN];
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    if ((rv = getaddrinfo(host, port, &hints, &servinfo)) != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
        return 1;
    }

    for (p = servinfo; p != NULL; p = p->ai_next) {
        if ((fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) == -1) {
            perror("client: socket error");
            continue;
        }

        if (connect(fd, p->ai_addr, p->ai_addrlen) == -1) {
            close(fd);
            perror("client: connect error");
            continue;
        }

        break;
    }

    if (p == NULL) {
        fprintf(stderr, "client: failed to connect\n");
        exit(2);
    }

    freeaddrinfo(servinfo);
    return fd;
}

同样的,首先使用 getaddrinfo() 获取可用的地址信息,然后调用 socket() 创建一个套接字,不同的是,客户端无需手动 bind(),在 connect() 的时候操作系统会自动给程序分配一个可用的端口。

有了这两个函数,就能很方便的同时监听多个端口和连接到不同地址了。

epoll 的使用

在 Linux 2.5.44 之后,增加了一个 epoll 的系统调用,这个系统调用可以实现高效的非阻塞网络 IO,使用也非常简单,主要分为三个步骤:

  1. 创建一个 epoll 文件描述符
  2. 添加关心的事件到 epoll 文件描述符中
  3. 等待关心的事件发生,然后处理

相比于 selectepoll 只返回发生了事件的文件描述符集,使得我们可以不再需要遍历所有的文件描述符,因此在有大量的连接而只有少数是活跃的时候,epoll 会非常高效。

对应上面三个步骤是三个系统调用:

  1. epoll 文件描述符创建 epoll_create1(int flags)
  2. epoll 关心事件操作 epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
  3. epoll 等待事件发生 epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

使用 epoll 编写网络程序的时候一般有如下框架:

#define MAX_EVENTS 10
/* 创建监听套接字 */
int listen_fd = listen_port(port);
/* 步骤 1:创建 epoll 文件描述符 */
int epoll_fd = epoll_create1(0);
struct epoll_event event;
/* 填入关心的事件,EPOLLIN 表示可读,EPOLLOUT 表示可写,EPOLLRDHUP 表示关闭,EPOLLET 表示边缘触发(Edge Trigger),EPOLLLT 表示水平触发(Level Trigger) */
event.events = EPOLLIN; 
/* 这里的 data 是一个 union,在事件发生的时候会随着事件一起返回 */
event.data.fd = listen_fd;

/* 步骤 2:将关心的事件添加到列表中 */
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event);

struct epoll_event events[MAX_EVENTS];

while (1) {
    /* 步骤 3:等待事件的发生,如果没有事件发生则会阻塞,直到有事件发生或者超时 */
    int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    /* 遍历事件列表 */
    for (int i = 0; i < nfds; ++i) {
        struct epoll_event ev = events[i];
        if (ev.data.fd == listen_fd) {
            /* 接受新连接... */
            continue;
        }

        if (ev.events & EPOLLIN) {
            /* 有数据传入,处理数据 */
        }

        if (ev.events & EPOLLOUT) {
            /* 缓冲区有空闲了,可以发送数据 */
        }
    }
} 

可以看到,使用 epoll 的程序最终都会进入一个主循环不断等待事件的发生并处理这些事件,这样的循环也叫做事件循环(Event Loop

根据这个框架,对于服务端而言,就是不断查看 listen_fd 上有没有新的连接传入,如果有,那就调用 accept4() 系统调用接受新的连接。然后监听这个连接上的事件并处理。

需要注意的是,对于监听套接字,不能使用 EPOLLET,否则会漏掉连接。EPOLLETEPOLLLT 的区别主要是:

  • EPOLLET 只在事件第一次发生的时候被触发,之后无论事件有没有被处理都不再触发,因此在调用 recv() 或者 send() 的时候,要循环调用至函数返回 EAGAIN 或者 EWOULDBLOCK 为止。
  • EPOLLLT 只要有关心的事件还没被处理,就会一直触发,send()recv() 没有处理完也没关系,下一次还是会被通知到。

在这个例子里,也就是说如果监听套接字上本来没有连接传入,这时候有新的连接传入了,对于 EPOLLET 而言,会通知事件循环。然而,调用 accept4() 只能处理一个连接,如果这时候有多个连接同时传入,那么第一个连接处理完之后,后面没有被处理的连接就会被忽略掉。因此,对于监听套接字而言,需要使用 EPOLLLT 电平触发。

因此,处理监听套接字上的连接可以这样操作:

if (event.data.fd == server_fd && (event.events & EPOLLIN)) {
    int new_fd =
        accept4(server_fd, (struct sockaddr *)&clients_addr,
                &sin_size, SOCK_NONBLOCK);
    send(new_fd, &meta, sizeof(struct filemetadata), 0);
    close(new_fd);
}

这里接受连接之后,马上发送文件的元信息给客户端,客户端接下来就会发起请求到数据连接的监听端口,并且发送文件分块请求信息,这时候,我们就可以利用 event.data.ptr 来存储相关的信息,以供后面发送文件块使用。

if (event.data.fd == data_fd && (event.events & EPOLLIN)) {
    int new_fd = accept4(data_fd, (struct sockaddr *)&clients_addr,
                         &sin_size, SOCK_NONBLOCK);

    struct request *req = malloc(sizeof(struct request));
    req->fd = new_fd;
    struct epoll_event data_event = {0};
    data_event.events = EPOLLIN | EPOLLRDHUP;
    data_event.data.ptr = req;

    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd, &data_event);
}

对于新的连接,我们首先期望收到请求文件块的信息,所以设置了 EPOLLIN。而收到了请求块之后,将请求信息保存下来,然后就可以开始根据请求信息开始发送数据了:

if (event.events & EPOLLIN) {
    struct request *req = event.data.ptr;
    struct request req_recv;
    int fd = req->fd;
    recv(fd, &req_recv, sizeof(struct request), 0);
    req->length = req_recv.length;
    req->offset = req_recv.offset;
    req->progress.written = 0;

    event.events = EPOLLOUT | EPOLLRDHUP | EPOLLET;
    epoll_ctl(epoll_fd, EPOLL_CTL_MOD, fd, &event);
}

我们关心的只有两个信息:文件的偏移量和文件块的大小,保存下来之后,我们就可以关心 EPOLLOUT 事件了,也就是等待套接字可写。这里等待套接字可写的意思其实是等待缓冲区有空位,而设置 EPOLLET 是为了避免缓冲区空而又没有数据可以写的时候一直通知造成忙等待。

之后当套接字可写的时候,我们就根据当前进度使劲往里面写入要发送的文件数据,直到写满缓冲区为止,然后记录我们新的进度,看看发送完了没有:

if (event.events & EPOLLOUT) {
    struct request *req = event.data.ptr;
    int fd = req->fd;
    fseek(fp, req->offset + req->progress.written, SEEK_SET);
    int size = fread(
        buffer, 1,
        MIN(BUFFER_LEN, req->length - req->progress.written), fp);
    while ((n = send(fd, buffer, size, 0)) > 0 && size > 0 &&
           req->progress.written < req->length) {
        req->progress.written += n;
        size -= n;
    }

    if (req->progress.written == req->length) {
        free(req);
        close(fd);
    }
}

对于 epoll 而言,当一个文件描述符被关闭的时候,也就会自动被移除出关心列表,所以这里可以不使用 epoll_ctl() 手动删除。

服务端的程序主要的逻辑就是这些,总结一下就是:

  1. 监听连接
  2. 接受连接
  3. 收发数据

有了服务端程序,客户端程序也就依葫芦画瓢,水到渠成了:

  1. 发起连接
  2. 收发数据

完整的项目代码可以到 https://github.com/howardlau1999/tcp-file-transfer 获取。