网络通信 C语言 Socket TCP Select Server

前言

工作中遇到各种各样的网络通信。有MQTT和CoAP这样的物联网应用层协议,也有各种自定义的TCP或UDP协议。使用各种不同的计算机语言和框架开发网络通信,例如Java的Netty框架,C语言原始socket,Python Socket。各有各的使用场景,难易程度相差巨大。Netty上手困难,C语言编写复杂,Python Socket上手容易。
本文将介绍如何使用select套接字实现一个TCP服务器。我曾经翻阅了很多网络资料和图书示例,很难找到一个满意的select示例。

示例简述

  • 主要通过select实现一个TCP Echo服务器
  • 使用Python脚本实现一个TCP客户端,模拟多个客户端执行连接 -> 发送数据 -> 接收数据 -> 关闭连接
  • 开发环境 Linux,构建工具CMake,编译器Linux GCC

tcp-select-server.c

代码实现

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/time.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include <assert.h>
#include <netdb.h>

#define BUF_SIZE    1024

/*
编译 gcc tcp-select-server.c -o tcp-select-server
运行 ./tcp-select-server 50018
*/
void print_sockaddr(struct sockaddr_in addr)
{
    // 保存点分十进制的地址
    char ip_address[INET_ADDRSTRLEN];
    int port;

    inet_ntop(AF_INET, &addr.sin_addr, ip_address, sizeof(ip_address));
    port = ntohs(addr.sin_port);
    printf("(%s:%d)\n", ip_address, port);
}

int main(int argc, char *argv[])
{
    int sfd = -1;
    struct addrinfo hints;
    struct addrinfo *result;
    struct addrinfo *rp;

    struct sockaddr_in client_addr;
    struct sockaddr_in peer_addr;
    fd_set read_fds;
    fd_set work_fds;

    struct timeval tout;
    int peer_addrlen;
    int client_addrlen;
    int optval;
    int max_sockfd;

    char recv_buf[BUF_SIZE];
    int port = 0;

    if (argc != 2) {
        fprintf(stderr, "usage: %s port\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_family = AF_UNSPEC;        /* 允许IPv4 或者 IPv6 */
    hints.ai_socktype = SOCK_STREAM;    /* TCP */
    hints.ai_flags = AI_PASSIVE;
    hints.ai_protocol = 0;
    hints.ai_canonname = NULL;
    hints.ai_addr = NULL;
    hints.ai_next = NULL;

    int s = getaddrinfo(NULL, argv[1], &hints, &result);
    if (s != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
        exit(EXIT_FAILURE);
    }
    for (rp = result; rp != NULL; rp = rp->ai_next) {
        sfd = socket(rp->ai_family, rp->ai_socktype,
                     rp->ai_protocol);
        if (sfd == -1)
            continue;

        if ((setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR,
                        &optval, sizeof (optval))) != 0)
            continue;

        if (bind(sfd, rp->ai_addr, rp->ai_addrlen) != 0)
            continue;

        if (listen (sfd, 5) != 0)
            continue;

        /* 成功 */
        break;
    }
    if (rp == NULL) {
        fprintf(stderr, "Could not bind\n");
        exit(EXIT_FAILURE);
    }
    freeaddrinfo(result);

    FD_ZERO(&read_fds);
    FD_SET(sfd, &read_fds);
    FD_SET(STDIN_FILENO, &read_fds);
    max_sockfd = sfd;

    while (1) {
        tout.tv_sec = 2;
        tout.tv_usec = 0;

        work_fds = read_fds;
        int ret = select (max_sockfd + 1, &work_fds, NULL, NULL, &tout);

        if (ret == 0) {
            continue;
        }
        if (ret == -1) {
            continue;
        }

        for (int i = 0; i < max_sockfd + 1; i++) {
            if (!FD_ISSET(i, &work_fds)) {
                continue;
            }

            int fd = i;
            if (fd == sfd) {
                client_addrlen = sizeof(client_addr);
                int cfd = accept(sfd, (struct sockaddr *)&client_addr,
                                 (socklen_t *)&client_addrlen);
                printf("accept fd:%d ", cfd);
                print_sockaddr(client_addr);

                if (cfd < 0) {
                    printf("accept cfd < 0!");
                    continue;
                }
                FD_SET(cfd, &read_fds);
                if (cfd > max_sockfd) {
                    max_sockfd = cfd;
                }
            } else {
                ssize_t num_read = recv(fd, recv_buf, sizeof(recv_buf), 0);
                if (num_read <= 0) {
                    printf ("client has left fd:%d\n", fd);
                    close(fd);
                    FD_CLR(fd, &read_fds);
                    continue;
                }

                recv_buf[num_read] = '\0';
                printf("receive %zd bytes: \"%s\" from fd:%d", num_read, recv_buf, fd);
                peer_addrlen = sizeof(peer_addr);
                getpeername(fd, (struct sockaddr *)&peer_addr, (socklen_t *)&peer_addrlen);
                print_sockaddr(peer_addr);

                send(fd, recv_buf, (size_t)num_read, 0);
            }
        }
    }

    return EXIT_SUCCESS;
}

代码说明

  • FD_SET(sfd, &read_fds) 把本地服务器套接字加入到读取列表中,一般这个sfd为3
  • work_fds = read_fds 调用select方法之前务必要把read_fds 复制给一个临时变量work_fds,然后再通过select (max_sockfd + 1, &work_fds, NULL, NULL, &tout),注意select将改变work_fdstout
  • cfd = accept(sfd, ..) 第一个连接的客户端的cfd一般为4,以此类推。
  • FD_SET(cfd, &read_fds); 接收到客户端连接后,务必把客户端的fd加入到read_fds
  • FD_CLR(fd, &read_fds);若recv获得长度为0,说明客户端已经断开连接,那么需要把客户端套接字从读取列表中移除
  • 若不使用work_fds临时变量方法,也可以使用链表存储所有被接受的客户端fd。网上很多代码通过一个数组保存读取列表,这样容易产生溢出。例如【linux下socket采用select编程Demo

CMake构建

CMakeLists.txt内容如下:

cmake_minimum_required(VERSION 3.13)
set(CMAKE_C_STANDARD 99)
add_executable(tcp-server tcp-server.c)

编译过程

mkdir -p build
cd build
cmake ..
make
# 最终生成可执行文件tcp-server

测试脚本

简要说明

编写一个python TCP客户端脚本,该脚本创建多个TCP客户端线程,每个线程工作过程如下:

  1. 延时一个随机时间后,连接服务器
  2. 延时一个随机时间后,向服务器发送内容,并等待回复
  3. 延时一个随机时间后,关闭服务器连接

tcp-multi-client.py

from concurrent.futures import ThreadPoolExecutor, wait
import time
import socket
import binascii
import random

HOST = '127.0.0.1'
PORT = 50018


def tcp_client():
    time.sleep(random.randint(1, 5))
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((HOST, PORT))
    print("connected remote", (HOST, PORT), "local", s.getsockname())
    request = bytearray([0x31, 0x32, 0x33, 0x34])

    time.sleep(random.randint(1, 5))
    s.sendall(request)
    response = s.recv(1024)
    print('received', binascii.hexlify(response), 'local', s.getsockname())

    time.sleep(random.randint(1, 5))
    print('closed local', s.getsockname())
    s.close()


# 创建一个线程池
executor = ThreadPoolExecutor(max_workers=5)
# 执行多次tcp_client
# executor.map(tcp_client, range(5))
all_task = [executor.submit(tcp_client) for _ in range(5)]
wait(all_task)

测试

【注意】代码多次调整,控制台输出内容与代码中打印细节不符。

先运行TCP服务器

# 可执行文件
cd build
./tcp-select-server 50018
# 运行python脚本后控制台输出
accept fd:4 (127.0.0.1:53704)
receive 4 bytes: "1234" from fd:4(127.0.0.1:53704)
accept fd:5 (127.0.0.1:53706)
accept fd:6 (127.0.0.1:53708)
accept fd:7 (127.0.0.1:53710)
accept fd:8 (127.0.0.1:53712)
receive 4 bytes: "1234" from fd:5(127.0.0.1:53706)
receive 4 bytes: "1234" from fd:8(127.0.0.1:53712)
client has left fd:4
receive 4 bytes: "1234" from fd:7(127.0.0.1:53710)
client has left fd:5
receive 4 bytes: "1234" from fd:6(127.0.0.1:53708)
client has left fd:8
client has left fd:7
client has left fd:6

再运行python脚本

python3 tcp-multi-client.py
# 控制台输出
connected remote ('127.0.0.1', 50018) local ('127.0.0.1', 53704)
received b'31323334' local ('127.0.0.1', 53704)
connected remote ('127.0.0.1', 50018) local ('127.0.0.1', 53706)
connected remote ('127.0.0.1', 50018) local ('127.0.0.1', 53708)
connected remote ('127.0.0.1', 50018) local ('127.0.0.1', 53710)
connected remote ('127.0.0.1', 50018) local ('127.0.0.1', 53712)
received b'31323334' local ('127.0.0.1', 53706)
received b'31323334' local ('127.0.0.1', 53712)
closed local ('127.0.0.1', 53704)
received b'31323334' local ('127.0.0.1', 53710)
closed local ('127.0.0.1', 53706)
received b'31323334' local ('127.0.0.1', 53708)
closed local ('127.0.0.1', 53712)
closed local ('127.0.0.1', 53710)
closed local ('127.0.0.1', 53708)

结果分析

  • 服务器依次使用了套接字编号 4,5,6,7,8。若出现第一个客户端套接字4已经断开,某客户端建立新连接时将会复用客户端4。也就是说空闲的客户端套接字将会被不断复用。

参考资料与代码仓库

本文代码仓库 c-socket-demo

  • 物联网图书推荐 CoAP基础 徐凯《IoT开发实战: CoAP卷》 2017 机械工业出版社【 京东链接
  • 物联网图书推荐 CoAPs进阶 徐凯 崔红鹏《密码技术与物联网安全:mbedtls开发实战》2019 机械工业出版社 【京东链接

推荐阅读更多精彩内容