1、什么是网络IO?
我们生活中有很多用到了网络的场景,微信朋友圈点赞、QQ回复消息、浏览网页...从用户设备联网的那一刻,就会产生服务端与客户端的通信,也就是网络IO,因为需要收发数据,所以这里对于任意一端,都是双向的输入和输出。
这里针对网络层面,那么我们就需要知道,最经典的网络分层模型:
软件层面,我们主要关注socket和协议栈,而网络IO,具体指发生在socket阶段的数据处理,它连接了客户端与服务端的数据通信。
我们知道,管道不会感知里面流动的是什么物质,水管输送水,天然气管道输送天然气,但它们都是管道,socket阶段的管道,我们称之为fd(File Descriptor,unix视万物为文件,而fd就是系统分配给已打开资源的唯一标识符)。
下图我们可以看到,在socket阶段,有多少个客户端,就有多少个“数据管道”。
它不进行数据类型的区分,微信消息、短视频流、网页静态资源...本质都是数据,全部通过数据管道进行传输。
服务器就像一个自来水厂,通过管道,供给城市中的千万个水龙头,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
效果如下:
(主题插件:One Dark Pro)
3、实现一个能收发消息的简单服务端(一消息一线程模式)
需求整体框图如下:
下面,我们逐步来拆解具体的实现。
① 首先,我们要创建一个简单的服务端入口,也就是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、功能验证
① 支持服务端与多客户端连接:
② 支持服务端同时与多客户端收发消息:
③ 支持收到“disconnect”后主动关闭客户端连接,且不影响已接连的客户端:
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;
}