02理解网络IO:实现服务与客户端通信

简介: 网络IO指客户端与服务端通过网络进行数据收发的过程,常见于微信、QQ等应用。本文详解如何用C语言实现一个支持多客户端连接的TCP服务端,涉及socket编程、线程处理及通信流程,并分析“一消息一线程”模式的优缺点。

1、什么是网络IO?

我们生活中有很多用到了网络的场景,微信朋友圈点赞、QQ回复消息、浏览网页...从用户设备联网的那一刻,就会产生服务端与客户端的通信,也就是网络IO,因为需要收发数据,所以这里对于任意一端,都是双向的输入和输出。

这里针对网络层面,那么我们就需要知道,最经典的网络分层模型:
image.png
软件层面,我们主要关注socket和协议栈,而网络IO,具体指发生在socket阶段的数据处理,它连接了客户端与服务端的数据通信。

我们知道,管道不会感知里面流动的是什么物质,水管输送水,天然气管道输送天然气,但它们都是管道,socket阶段的管道,我们称之为fd(File Descriptor,unix视万物为文件,而fd就是系统分配给已打开资源的唯一标识符)。
下图我们可以看到,在socket阶段,有多少个客户端,就有多少个“数据管道”。
image.png
它不进行数据类型的区分,微信消息、短视频流、网页静态资源...本质都是数据,全部通过数据管道进行传输。

服务器就像一个自来水厂,通过管道,供给城市中的千万个水龙头,fd就是建立在客户端与服务端之间的管道,网络IO就是对这一过程的描述。

问题在于,城市中的水龙头太多了,简直数不过来,而水厂却只有寥寥几个,可能一个水厂就要负责一个片区中千万个水龙头的供水需求,那么我们要怎么分配和管理这些管道资源,才能让每个人、在任何时刻都觉得,自己的水龙头没有停水呢?

上面这个问题涉及的比较广泛,本篇介绍的主要内容,是如何实现一个简单的tcp客户端,让一部分人先吃上水,需求如下:

还是用自来水厂作类比,我们需要盖一座“水厂”,它的职责很简单:给住户分配管道、供水和接收废水;
当有住户请求提供“水资源”时,水厂需要分配一条管道,让住户既能把生活废水排给水厂,又能让水厂把水资源输送过去。
更具体地:
Ⅰ、实现一个服务端,支持多个客户端连接;
Ⅱ、服务端需要把客户端发来的消息,同样返回给客户端;
Ⅲ、当收到客户端发来“disconnect”,主动断开与客户端的连接;
Ⅳ、可靠性:不能出现连接阻塞、消息阻塞;

2、可参考的环境&工具配置

ubuntu 24.10,开源镜像:https://mirrors.tuna.tsinghua.edu.cn/ubuntu-releases/24.10/
VMware中配置ubuntu:https://blog.csdn.net/air_729/article/details/143105854
在ubuntu中安装C/C++开发环境:https://blog.csdn.net/qq_33867131/article/details/126540537
(虚拟机安装ssh服务:sudo apt install openssh-server)
vscode安装:https://blog.csdn.net/dengjin20104042056/article/details/146276348
使用remote-ssh插件连接虚拟机:https://blog.csdn.net/qq_46123200/article/details/136193576
网络调试工具:https://blog.csdn.net/fengbingchun/article/details/140397988
效果如下:
image.png
(主题插件:One Dark Pro)

3、实现一个能收发消息的简单服务端(一消息一线程模式)

需求整体框图如下:
image.png

下面,我们逐步来拆解具体的实现。

① 首先,我们要创建一个简单的服务端入口,也就是socket套接字,设置要使用什么类型的通信协议,同时为了保证代码的健壮性,假如服务端创建失败,也要有一定的处理;

    /**
     * 1、创建新套接字的系统调用,返回一个文件描述符(整数)用于后续操作
     * AF_INET:地址族(Address Family),表示使用IPv4协议
     * SOCK_STREAM:套接字类型,表示面向连接的可靠字节流(自动选择TCP协议)
     * 0:表示使用默认协议
     */
    int sockfd = socket(AF_INET, SOCK_STREAM, DEFAULT_PROTOCOL);//成功时返回非负整数的文件描述符
    if (sockfd == ERROR_CODE) {
   
        printf("socket failed : %s\n ",strerror(errno));
        return sockfd;
    }
    printf("socket init success!\n");

② 我们是本地启动的虚拟机,所以ip是确定的,但是服务端还需要绑定地址和端口号,这样外部的请求才可以通过具体的“地址”,来访问到服务端的具体服务,我们会用到结构体sockaddr_in,用于将①中创建好的套接字,与需要监听的地址、端口进行绑定,同样我们要在绑定失败时,进行处理;


    /**
     * 2、创建套接字地址结构
     * 用于指定要绑定的IP地址和端口号
     */
    struct sockaddr_in servaddr;
    memset(&servaddr, ZERO_INIT, sizeof(servaddr)); // 初始化结构体
    servaddr.sin_family = AF_INET;// 设置地址族为 IPv4
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);// 设置监听地址为任意本地网卡0.0.0.0
    servaddr.sin_port = htons(DEFAULT_PORT);//设置监听端口为 2000(0到1023为系统占用端口)

    /**
     * 3、绑定套接字和地址
     * sockfd:要绑定的套接字文件描述符
     * (struct sockaddr*)&servaddr:指向sockaddr_in结构体的指针,包含要绑定的地址信息
     * sizeof(struct sockaddr):地址结构体的大小
     * 返回值:成功时返回0,失败时返回-1
     */
    if (bind(sockfd,(struct sockaddr*)&servaddr,sizeof(struct sockaddr)) == ERROR_CODE) {
   
        printf("bind failed : %s\n ",strerror(errno));
        return ERROR_CODE;
    }

③ 服务端要感知到外部的连接请求,那么就需要有人“站岗”,也就是监听(listen)有没有请求连接过来,这样服务端才会知道后续什么时候、需要给哪个请求来分配“管道”;

    /**
     * 4、启动监听
     * sockfd:要监听的套接字文件描述符
     * 10:最大允许的等待连接请求数
     */
    if (listen(sockfd, CONNECT_COUNT) == ERROR_CODE) {
   
        printf("listen failed : %s\n ",strerror(errno));
        return ERROR_CODE;
    }
    printf("listen start!\n");

④ 通过监听,我们发现了外部的请求,那么要如何处理呢?我们将每一个请求,视为一个客户端,为每一个请求,分配一个客户端的fd,也就是对应的数据管道,用于后续的数据传输,同时,我们需要分配一个专门的线程来处理客户端请求过来的数据,所以这里要持续不断地进行accept,避免后面的请求被阻塞。同时我们也要注意线程任务结束后,要回收对应的资源;

    struct sockaddr_in clientaddr;
    socklen_t socklen = sizeof(clientaddr);
    printf("waiting accept!\n");
    while (LOOP) {
   
        /**5、处理连接请求
         * sockfd:要接受连接请求的套接字文件描述符
         * (struct sockaddr*)&clientaddr:指向sockaddr_in结构体的指针,存储了客户端的地址信息
         * &socklen:指向socklen_t类型变量的指针,用于存储客户端地址信息的长度
         * 返回值:成功时返回非负整数的文件描述符,失败时返回-1
         */
        int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &socklen);
        if (clientfd == ERROR_CODE) {
   
            printf("accept failed : %s\n ",strerror(errno));
            return clientfd;
        }
        printf("accept success!\n");

        // 每收到一个连接,对应分配一个线程任务进行处理
        pthread_t recvThreadID;
        pthread_create(&recvThreadID, NULL, recvThread, &clientfd);
        pthread_detach(recvThreadID);// 设置线程可分离状态,结束后自动回收资源
    }

⑤ 处理客户端任务线程,根据需求,我们要能收到客户端发来的数据,并且回复一个数据,当收到客户端发来”disconnect“时,主动断开连接。这里需要用到recv和send,而且在recv时,对收到的消息进行判断,主动close掉客户端的fd;

/**
 * @brief 处理客户端数据接收和回显的线程函数
 * 
 * 该函数作为线程入口,负责接收客户端发送的数据,并将接收到的数据原样返回给客户端。
 * 当接收到的数据长度为 0 或客户端发送 "disconnect" 时,关闭客户端连接并退出线程。
 * 
 * @param arg 指向客户端套接字文件描述符的指针
 * @return void* 缺省类型
 */
void *recvThread(void *arg) {
   
    int clientfd = *(int *)arg;//从可变参数列表中获取客户端套接字文件描述符

    printf("waiting recv!\n");
    while (LOOP) {
   
        char buf[BUFFER_SIZE] = {
   0};
        /**
         * 6、接收客户端数据
         * clientfd:要接收数据的客户端套接字文件描述符
         * buf:指向接收数据的缓冲区的指针
         * BUFFER_SIZE:要接收的最大字节数
         * MSG_SIGNAL:标志位,指定接收行为
         * 返回值:成功时返回实际接收的字节数,若返回0表示连接已关闭,若返回-1表示出错
         */
        int recvCount = recv(clientfd, buf, BUFFER_SIZE, MSG_SIGNAL);// MSG_SIGNAL不能出现重定义
        if (recvCount == NO_MESSAGE_SIZE) {
   
            printf("recv stop : %s\n ", strerror(errno));
            close(clientfd);// 关闭客户端套接字文件描述符
            break;
        } else if (strcmp(buf, "disconnect") == 0) {
   //收到disconnect,结束线程任务
            printf("client closed!\n");
            close(clientfd);// 关闭客户端套接字文件描述符
            break;
        } else {
   
            printf("recv message: %s, count = %d\n", buf,recvCount);
        }
        /**
         * 7、发送数据给客户端
         * clientfd:要发送数据的客户端套接字文件描述符
         * buf:指向要发送数据的缓冲区的指针
         * recvCount:要发送的字节数
         * MSG_SIGNAL:标志位,指定发送行为
         * 返回值:成功时返回实际发送的字节数,若返回-1表示出错
         */
        int sendCount = send(clientfd, buf, recvCount, MSG_SIGNAL);
        if (sendCount == NO_MESSAGE_SIZE) {
   
            printf("send failed : %s\n ", strerror(errno));
            close(clientfd);// 关闭客户端套接字文件描述符
            break;
        } else {
   
            printf("send message: %s, count = %d\n", buf,sendCount);
        }
    }
}

4、功能验证

① 支持服务端与多客户端连接:
image.png

② 支持服务端同时与多客户端收发消息:
image.png

③ 支持收到“disconnect”后主动关闭客户端连接,且不影响已接连的客户端:
image.png

5、“一消息一线程”模式的缺点

① cpu资源浪费:频繁创建/销毁线程导致大量CPU时间消耗在系统调用和线程调度上,当线程数 > CPU核心数时,操作系统频繁切换线程,导致cache失效;
② 稳定性差:占用过多内存,导致系统稳定性没有保障;
③ 支持的连接数有限:每个线程独立持有Socket连接,操作系统一般对进程的fd数量有限制(默认1024);
④ 扩展性差:如果遇到并发场景,会因为应对不了突发流量直接挂掉, 并且线程模型与本地状态强耦合,难以迁移到分布式系统;

6、完整版代码

#include <stdio.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/queue.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
/** 测试功能需要用到的命令
 * 
 * 编译二进制文件
 * gcc -o NetWorkio NetWorkio.c
 * 
 * 执行二进制文件
 * ./NetWorkio 
 * 
 * 查看端口的连接状态
 * netstat -anop | grep 2000
 * 
 */

// 错误码
#define ERROR_CODE -1
// 连接数量
#define CONNECT_COUNT 10
// 接收消息的buffer长度
#define BUFFER_SIZE 1024
// 返回值
#define RETURN_CODE 0
// 消息数据长度
#define NO_MESSAGE_SIZE 0
// 默认协议类型
#define DEFAULT_PROTOCOL 0
// 初始化默认值
#define ZERO_INIT 0
// 默认端口号
#define DEFAULT_PORT 2000
// 循环常量
#define LOOP 1
// 消息发送模式(修复宏定义冲突)
#define MSG_SIGNAL 0  // 使用系统自带的定义


/**
 * @brief 处理客户端数据接收和回显的线程函数
 * 
 * 该函数作为线程入口,负责接收客户端发送的数据,并将接收到的数据原样返回给客户端。
 * 当接收到的数据长度为 0 或客户端发送 "disconnect" 时,关闭客户端连接并退出线程。
 * 
 * @param arg 指向客户端套接字文件描述符的指针
 * @return void* 缺省类型
 */
void *recvThread(void *arg) {
   
    int clientfd = *(int *)arg;//从可变参数列表中获取客户端套接字文件描述符

    printf("waiting recv!\n");
    while (LOOP) {
   
        char buf[BUFFER_SIZE] = {
   0};
        /**
         * 6、接收客户端数据
         * clientfd:要接收数据的客户端套接字文件描述符
         * buf:指向接收数据的缓冲区的指针
         * BUFFER_SIZE:要接收的最大字节数
         * MSG_SIGNAL:标志位,指定接收行为
         * 返回值:成功时返回实际接收的字节数,若返回0表示连接已关闭,若返回-1表示出错
         */
        int recvCount = recv(clientfd, buf, BUFFER_SIZE, MSG_SIGNAL);// MSG_SIGNAL不能出现重定义
        if (recvCount == NO_MESSAGE_SIZE) {
   
            printf("recv stop : %s\n , clientfd = %d", strerror(errno), clientfd);
            close(clientfd);// 关闭客户端套接字文件描述符
            break;
        } else if (strcmp(buf, "disconnect") == 0) {
   //收到disconnect,结束线程任务
            printf("client closed!, clientfd = %d\n", clientfd);
            close(clientfd);// 关闭客户端套接字文件描述符
            break;
        } else {
   
            printf("recv message: %s, count = %d, clientfd = %d\n", buf, recvCount, clientfd);
        }
        /**
         * 7、发送数据给客户端
         * clientfd:要发送数据的客户端套接字文件描述符
         * buf:指向要发送数据的缓冲区的指针
         * recvCount:要发送的字节数
         * MSG_SIGNAL:标志位,指定发送行为
         * 返回值:成功时返回实际发送的字节数,若返回-1表示出错
         */
        int sendCount = send(clientfd, buf, recvCount, MSG_SIGNAL);
        if (sendCount == NO_MESSAGE_SIZE) {
   
            printf("send stop : %s\n , clientfd = %d", strerror(errno), clientfd);
            close(clientfd);// 关闭客户端套接字文件描述符
            break;
        } else {
   
            printf("send message: %s, count = %d, clientfd = %d\n", buf, sendCount, clientfd);
        }
    }
}


int main()
{
   
    /**
     * 1、创建新套接字的系统调用,返回一个文件描述符(整数)用于后续操作
     * AF_INET:地址族(Address Family),表示使用IPv4协议
     * SOCK_STREAM:套接字类型,表示面向连接的可靠字节流(自动选择TCP协议)
     * 0:表示使用默认协议
     */
    int sockfd = socket(AF_INET, SOCK_STREAM, DEFAULT_PROTOCOL);//成功时返回非负整数的文件描述符
    if (sockfd == ERROR_CODE) {
   
        printf("socket failed : %s\n ",strerror(errno));
        return sockfd;
    }
    printf("socket init success!\n");

    /**
     * 2、创建套接字地址结构
     * 用于指定要绑定的IP地址和端口号
     */
    struct sockaddr_in servaddr;
    memset(&servaddr, ZERO_INIT, sizeof(servaddr)); // 初始化结构体
    servaddr.sin_family = AF_INET;// 设置地址族为 IPv4
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);// 设置监听地址为任意本地网卡0.0.0.0
    servaddr.sin_port = htons(DEFAULT_PORT);//设置监听端口为 2000(0到1023为系统占用端口)

    /**
     * 3、绑定套接字和地址
     * sockfd:要绑定的套接字文件描述符
     * (struct sockaddr*)&servaddr:指向sockaddr_in结构体的指针,包含要绑定的地址信息
     * sizeof(struct sockaddr):地址结构体的大小
     * 返回值:成功时返回0,失败时返回-1
     */
    if (bind(sockfd,(struct sockaddr*)&servaddr,sizeof(struct sockaddr)) == ERROR_CODE) {
   
        printf("bind failed : %s\n ",strerror(errno));
        return ERROR_CODE;
    }

    /**
     * 4、启动监听
     * sockfd:要监听的套接字文件描述符
     * 10:最大允许的等待连接请求数
     */
    if (listen(sockfd, CONNECT_COUNT) == ERROR_CODE) {
   
        printf("listen failed : %s\n ",strerror(errno));
        return ERROR_CODE;
    }
    printf("listen start!\n");

    struct sockaddr_in clientaddr;
    socklen_t socklen = sizeof(clientaddr);
    printf("waiting accept!\n");
    while (LOOP) {
   
        /**5、处理连接请求
         * sockfd:要接受连接请求的套接字文件描述符
         * (struct sockaddr*)&clientaddr:指向sockaddr_in结构体的指针,存储了客户端的地址信息
         * &socklen:指向socklen_t类型变量的指针,用于存储客户端地址信息的长度
         * 返回值:成功时返回非负整数的文件描述符,失败时返回-1
         */
        int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &socklen);
        if (clientfd == ERROR_CODE) {
   
            printf("accept failed : %s\n ",strerror(errno));
            return clientfd;
        }
        printf("accept success!\n");

        // 每收到一个连接,对应分配一个线程任务进行处理
        pthread_t recvThreadID;
        pthread_create(&recvThreadID, NULL, recvThread, &clientfd);
        pthread_detach(recvThreadID);// 设置线程可分离状态,结束后自动回收资源
    }

    getchar();// 暂停程序,等待用户输入后退出

    printf("exit!\n");

    return RETURN_CODE;
}
目录
相关文章
|
8月前
|
JSON 中间件 Go
Go 网络编程:HTTP服务与客户端开发
Go 语言的 `net/http` 包功能强大,可快速构建高并发 HTTP 服务。本文从创建简单 HTTP 服务入手,逐步讲解请求与响应对象、URL 参数处理、自定义路由、JSON 接口、静态文件服务、中间件编写及 HTTPS 配置等内容。通过示例代码展示如何使用 `http.HandleFunc`、`http.ServeMux`、`http.Client` 等工具实现常见功能,帮助开发者掌握构建高效 Web 应用的核心技能。
427 61
|
9月前
|
监控 应用服务中间件 Linux
掌握并发模型:深度揭露网络IO复用并发模型的原理。
总结,网络 I/O 复用并发模型通过实现非阻塞 I/O、引入 I/O 复用技术如 select、poll 和 epoll,以及采用 Reactor 模式等技巧,为多任务并发提供了有效的解决方案。这样的模型有效提高了系统资源利用率,以及保证了并发任务的高效执行。在现实中,这种模型在许多网络应用程序和分布式系统中都取得了很好的应用成果。
263 35
|
8月前
|
运维 网络协议 Go
Go网络编程:基于TCP的网络服务端与客户端
本文介绍了使用 Go 语言的 `net` 包开发 TCP 网络服务的基础与进阶内容。首先简述了 TCP 协议的基本概念和通信流程,接着详细讲解了服务端与客户端的开发步骤,并提供了简单回显服务的示例代码。同时,文章探讨了服务端并发处理连接的方法,以及粘包/拆包、异常检测、超时控制等进阶技巧。最后通过群聊服务端的实战案例巩固知识点,并总结了 TCP 在高可靠性场景中的优势及 Go 并发模型带来的便利性。
|
9月前
|
网络协议 安全 Devops
Infoblox DDI (NIOS) 9.0 - DNS、DHCP 和 IPAM (DDI) 核心网络服务管理
Infoblox DDI (NIOS) 9.0 - DNS、DHCP 和 IPAM (DDI) 核心网络服务管理
353 4
|
10月前
|
机器学习/深度学习 人工智能 安全
从攻防演练到AI防护:网络安全服务厂商F5的全方位安全策略
从攻防演练到AI防护:网络安全服务厂商F5的全方位安全策略
299 8
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
4月前
|
Java Unix Go
【Java】(8)Stream流、文件File相关操作,IO的含义与运用
Java 为 I/O 提供了强大的而灵活的支持,使其更广泛地应用到文件传输和网络编程中。!但本节讲述最基本的和流与 I/O 相关的功能。我们将通过一个个例子来学习这些功能。
223 1
|
Java 大数据
解析Java中的NIO与传统IO的区别与应用
解析Java中的NIO与传统IO的区别与应用
|
Java 大数据 API
Java 流(Stream)、文件(File)和IO的区别
Java中的流(Stream)、文件(File)和输入/输出(I/O)是处理数据的关键概念。`File`类用于基本文件操作,如创建、删除和检查文件;流则提供了数据读写的抽象机制,适用于文件、内存和网络等多种数据源;I/O涵盖更广泛的输入输出操作,包括文件I/O、网络通信等,并支持异常处理和缓冲等功能。实际开发中,这三者常结合使用,以实现高效的数据处理。例如,`File`用于管理文件路径,`Stream`用于读写数据,I/O则处理复杂的输入输出需求。
791 12
|
Java 数据处理
Java IO 接口(Input)究竟隐藏着怎样的神秘用法?快来一探究竟,解锁高效编程新境界!
【8月更文挑战第22天】Java的输入输出(IO)操作至关重要,它支持从多种来源读取数据,如文件、网络等。常用输入流包括`FileInputStream`,适用于按字节读取文件;结合`BufferedInputStream`可提升读取效率。此外,通过`Socket`和相关输入流,还能实现网络数据读取。合理选用这些流能有效支持程序的数据处理需求。
447 2

热门文章

最新文章