网络通信 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 机械工业出版社 【京东链接
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容