Linux 网络编程:TCP协议Socket开发全流程,理解多线程多进程实现的多连接网络通讯模型

引言

本文需掌握前两篇博客内容:Linux 网络编程:深度理解网络字节序与主机字节序、大端字节序与小端字节序-CSDN博客

Linux网络编程:Socket套接字编程概念及常用API接口介绍-CSDN博客

了解了 Socket 编程API介绍及网络字节序的转换,接下来就可以入门 TCP 协议的 Socket 网络通讯了,本文将会介绍基本的 TCP 协议下socket 编程流程,实现单个服务端-客户端连接模型、基于多线程、多进程实现的支持多个连接的服务端模型。依旧是作为笔者的学习笔记,为巩固大家的学习成果同时方便之后的复习,话不多说开始吧!

声明:本文借鉴了尚硅谷的 Linux 应用层开发课程内容及文档。

一、TCP协议的 Socket 编程基本流程

TCP socket 网络编程的核心在于Client-Server(客户端-服务器)模型。我们都知道:在Linux哲学中,"一切皆文件",因此Socket本质上也是一种文件描述符(File Descriptor, fd)。

在编写代码之前,我们需要先在脑海中建立起这个流程图。注意:我们学习计算机网络时了解过网络的分层概念,TCP的三次握手(3-way handshake)在这个过程中由操作系统内核自动完成的,我们在编码阶段其实并不需要关注三次握手是如何建立的,这就是分层的好处。

下面我将从服务端客户端两个角度,按顺序详细讲解一个 TCP 连接的建立流程。

1.1 服务端流程(Server-Side)

服务端的核心任务是:

  • socket(): 创建一个socket(买个手机)。

  • bind(): 绑定IP和端口(插上电话卡,有了号码)。

  • listen(): 开启监听模式(等待电话响)。

  • accept(): 阻塞等待,接收客户端连接(接听电话)。

  • send/recv(): 进行通讯(与客户端通话)

  • close(): 通讯关闭(挂断电话)

典型流程如下图:

socket() → bind() → listen() → accept() → send/recv() → close()

① socket() ------ 创建监听套接字

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

AF_INET 说明时IPv4地址,SOCK_STREAM: 面向 TCP 建立连接,返回值是监听 socket 的文件描述符

注意:它不是通信用的 socket,而是"接客"的 socket。

② bind() ------ 绑定 IP 和端口

之前的文章讲过 sockaddr_in 和 htonl/htons,这里不再展开。

目的:告诉内核"我监听的是哪个 IP + 端口"。

若不 bind,内核默认分配随机端口,那就无法作为服务端使用。

bind() 正确示例
cpp 复制代码
struct sockaddr_in addr;
addr.sin_family = AF_INET;
 
// 端口号转网络字节序
addr.sin_port = htons(8080);
 
// IP 地址转网络字节序
addr.sin_addr.s_addr = htonl(INADDR_ANY);
 
// 绑定
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

③ listen() ------ 让 socket 进入监听状态

cpp 复制代码
listen(sockfd, backlog);

作用:将 socket 变为被动监听 socket ,backlog 设置内核连接队列大小

监听 socket 不参与数据传输,只负责"接收连接请求"。

④ accept() ------ 接受客户端连接(三次握手后)

cpp 复制代码
int client_fd = accept(sockfd, NULL, NULL);

accept() 是一个很重要的阻塞函数,特点:

  • 阻塞等待客户端连接

  • 返回一个新的 client_fd,同样是一个文件描述符

  • client_fd 用来和客户端通信

  • 原本的 sockfd 继续用于 accept,多次 accept 就能接多个连接,建立多个 client_fd

⑤ send()/recv() ------ 进行数据传输

cpp 复制代码
recv(sockfd, read_buf, 1024, 0);
send(sockfd, write_buf, 1024, 0);

此处才是真正的收发数据过程。

⑥ close() ------ 关闭连接

客户端或服务端任意一方关闭,连接就断开。

服务端一般:

  • 关闭 connfd(每个客户一个)

  • 程序退出时关闭 sockfd

1.2 客户端流程(Client-Side)

客户端的流程更简单:

  • socket(): 创建一个socket(买个手机)。

  • connect(): 发起连接请求(拨打电话)。

  • send/recv(): 进行通讯(与服务端通话)

  • close(): 通讯关闭(挂断电话)

socket() → connect() → send/recv() → close()

1.3 单个TCP socket 连接示例

下面给出单个 socket 连接的示例代码:

服务端 (single_conn_server.c) :

cpp 复制代码
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd,result) \
    if (result < 0)              \
    {                            \
        perror(cmd);             \
        return -1;               \
    }                            \
    
void *read_from_client(void* arg)
{
    int client_fd = *(int*) arg;
    char* read_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!read_buf)
    {
        perror("malloc server read_buf");
        return (void*)1;
    }
    
    // 接收client_fd文件描述符下的文件的内存
    // 只要能接收到数据,就正常使用,一直挂起
    while (count = recv(client_fd,read_buf,1024,0))
    {
        if (count < 0)
        {
            // 错误输出
            perror("count");
        }
        
        /* code */
        fputs(read_buf,stdout);
    }
    
    // 线程执行到这说明循环结束,客户端已经关闭了请求
    printf("客户端请求关闭\n");
    // 释放内存
    free(read_buf);

    return NULL;
}

void* write_to_client(void* arg)
{
    int client_fd = *(int*) arg;
    char* write_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!write_buf)
    {
        perror("malloc server read_buf");
        return (void*)1;
    }

    while (fgets(write_buf,1024,stdin) != NULL)
    {
        /*发送数据*/
        count = send(client_fd,write_buf,1024,0);
        if (count < 0)
        {
            perror("send");
        }
    }

    shutdown(client_fd,SHUT_WR);
    free(write_buf);
    return NULL;
}

int main(int argc, char const *argv[])
{
    pthread_t pid_read,pid_write;
    // 定义客户端与服务端的套结字数据结构,供之后填写
    struct sockaddr_in server_addr,client_addr;
    // 清空
    memset(&server_addr,0,sizeof(server_addr));
    memset(&client_addr,0,sizeof(client_addr));

    // 填写服务段地址,指定协议
    server_addr.sin_family = AF_INET;
    // 填写ip地址 INADDR_ANY就是0.0.0.0
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 填写端口号
    server_addr.sin_port = htons(6666);
    
    // 网络编程流程介绍
    // 1. socket套结字创建,SOCK_STREAM说明使用的流传输,是TCP协议
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",sockfd);

    // 2. 绑定地址
    int temp_result = bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    handle_error("bind",temp_result);

    // 3. 进入监听状态
    temp_result = listen(sockfd,128);
    handle_error("listen",temp_result);

    // 4. 获取客户端的连接
    socklen_t cliaddr_len = sizeof(client_addr);
    // 返回的文件描述符才是能够和客户端通讯的文件描述符
    int clientfd = accept(sockfd,(struct sockaddr*)&client_addr,&cliaddr_len);
    handle_error("accept",clientfd);

    printf("与客户端%s %d建立连接 文件描述符是%d\n",inet_ntoa(client_addr.sin_addr),
    ntohs(client_addr.sin_port),clientfd);


    pthread_create(&pid_read,NULL,read_from_client,&clientfd);
    pthread_create(&pid_write,NULL,write_to_client,&clientfd);

    pthread_join(pid_read,NULL);
    pthread_join(pid_write,NULL);

    printf("释放资源\n");
    close(sockfd);
    close(clientfd);
    return 0;
}

客户端 (single_conn_client.c) :

cpp 复制代码
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd,result) \
    if (result < 0)              \
    {                            \
        perror(cmd);             \
        return -1;               \
    }                            \
    

void *read_from_server(void* arg)
{
    int client_fd = *(int*) arg;
    char* read_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!read_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }
    
    // 接收client_fd文件描述符下的文件的内存
    // 只要能接收到数据,就正常使用,一直挂起
    while (count = recv(client_fd,read_buf,1024,0))
    {
        if (count < 0)
        {
            // 错误输出
            perror("count");
        }
        
        /* code */
        fputs(read_buf,stdout);
    }
    
    // 线程执行到这说明循环结束,客户端已经关闭了请求
    printf("服务端请求关闭\n");
    // 释放内存
    free(read_buf);

    return NULL;
}

void* write_to_server(void* arg)
{
    int client_fd = *(int*) arg;
    char* write_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!write_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }

    while (fgets(write_buf,1024,stdin) != NULL)
    {
        /*发送数据*/
        count = send(client_fd,write_buf,1024,0);
        if (count < 0)
        {
            perror("send");
        }
    }

    shutdown(client_fd,SHUT_WR);
    free(write_buf);
    return NULL;
}

int main(int argc, char const *argv[])
{
    pthread_t pid_read,pid_write;
    // 定义客户端与服务端的套结字数据结构,供之后填写
    struct sockaddr_in server_addr,client_addr;
    // 清空
    memset(&server_addr,0,sizeof(server_addr));
    memset(&client_addr,0,sizeof(client_addr));

    // 填写服务端地址,指定协议
    server_addr.sin_family = AF_INET;
    // 填写ip地址 本机地址127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 填写端口号 6666 这里端口号需要与服务端的端口号对应上
    server_addr.sin_port = htons(6666);

    // 填写客户端地址,指定协议ipv4
    client_addr.sin_family = AF_INET;
    // 填写ip地址 
    inet_pton(AF_INET,"192.168.200.131",&client_addr.sin_addr.s_addr);
    // 填写端口号
    client_addr.sin_port = htons(8888);
    
    // 客户端网络编程流程介绍
    // 1. socket套结字创建,SOCK_STREAM说明使用的流传输,是TCP协议
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",sockfd);

    // 2. 绑定地址
    int temp_result = bind(sockfd,(struct sockaddr*)&client_addr,sizeof(client_addr));
    handle_error("bind",temp_result);

    // 3. 主动连接服务端
    temp_result = connect(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    handle_error("connect",temp_result);

    printf("连接上服务端%s %d\n",inet_ntoa(server_addr.sin_addr),
    ntohs(server_addr.sin_port));

    pthread_create(&pid_read,NULL,read_from_server,&sockfd);
    pthread_create(&pid_write,NULL,write_to_server,&sockfd);

    pthread_join(pid_read,NULL);
    pthread_join(pid_write,NULL);

    printf("释放资源\n");
    close(sockfd);
    return 0;
}

案例测试:

打开 Xshell 工具进行远程测试:

我已经用 Makefile 文件进行了可执行性目标文件的生成,我们直接进行测试

可以看到客户端与服务端已经建立起了连接,我们发送数据看连接是否稳定:

可以看到 TCP 协议建立的是可靠稳定的传输服务,在单个连接的情况下服务端与客户端都可以进行相互通讯发送消息,如果还想更加直观地看到二者相互通讯的过程,我们还可在上述文件的接收消息线程中,打印二者的文件描述符、二者的IP地址等信息。

在两端发送消息的线程函数中,我还添加了shutdown来测试TCP四次挥手的特性:

cpp 复制代码
shutdown(client_fd,SHUT_WR);

当我们在客户端或者是服务端按下 Ctrl+D,断开连接时:

这张是客户端按下 Ctrl+D,断开连接,客户端关闭读写功能,不能向客户端发送数据,但是连接并未就此断开,服务端的发送数据功能还在继续,客户端还能接收到服务端的内容

服务端也是同理。

二、服务端如何支持多个客户端连接?

真实世界中,服务器通常需要同时服务多个客户端。例如:

  • Web 服务器需同时响应多个浏览器请求。

  • 聊天服务器需广播消息给所有在线用户;。

  • 游戏服务器需实时同步多个玩家状态。

单连接模型只能处理一个客户端 ,一旦 accept() 获得 client_fd,就要一直 recv()/send() ,不可能继续处理新连接。

由于 accept()是阻塞的,同时而 recv() 也是阻塞的。若不引入并发机制,后续连接将被"饿死"。

为每个客户端连接创建独立的执行上下文,也就是进程或者是线程 ,让主线程专注于 accept()

三、多进程实现多连接服务端

多进程模型是最经典的方式,每 accept 一个连接就 fork 一个子进程,让它负责通信。

3.1 关键思路

cpp 复制代码
while (1) {
    client_fd = accept();
    pid = fork();

    if (pid == 0) {       // 子进程
        close(sockfd);    // 关闭监听socket
        handle(client_fd);   // 处理客户端通信
        exit(0);
    }else if(pid > 0){    // 父进程
        close(client_fd); // 不需要处理子进程内容,关闭连接socket
    }

    close(connfd);        // 父进程关闭通信socket
}
服务端代码实现:
cpp 复制代码
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>

#define handle_error(cmd,result) \
    if (result < 0)              \
    {                            \
        perror(cmd);             \
        return -1;               \
    }                            \
    
void zombie_dealer(int sig) {
    pid_t pid;
    int status;
    // 一个SIGCHLD可能对应多个子进程的退出
    // 使用while循环回收所有退出的子进程,避免僵尸进程的出现
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        if (WIFEXITED(status)) {
            printf("子进程: %d 以 %d 状态正常退出,已被回收\n", pid, WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("子进程: %d 被 %d 信号杀死,已被回收\n", pid, WTERMSIG(status));
        } else {
            printf("子进程: %d 因其它原因退出,已被回收\n", pid);
        }
    }
}

void *read_from_client_then_write(void* arg)
{
    int client_fd = *(int*)arg;

    ssize_t count = 0,send_count = 0;
    char* read_buf = NULL;
    char* write_buf = NULL;

    read_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!read_buf)
    {
        printf("服务端读缓存创建异常 断开连接\n");
        shutdown(client_fd,SHUT_WR);
        close(client_fd);
        perror("malloc server read_buf");
        return NULL;
    }

    write_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!write_buf)
    {
        printf("服务端写缓存创建异常 断开连接\n");
        shutdown(client_fd,SHUT_WR);
        close(client_fd);
        perror("malloc server write_buf");
        return NULL;
    }

    while ((count = recv(client_fd,read_buf,1024,0)))
    {
        // recv获取失败返回-1
        if (count < 0)
        {
            perror("recv");
        }
        // 接收数据打印到控制台
        printf("receive message from client_fd %d:\n %s\n",client_fd,
        read_buf);
        // 收到数据的消息写到写缓存
        strcpy(write_buf,"received~\n");
        send_count = send(client_fd,write_buf,102,0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    // 客户端输入ctrl+d时退出循环
    // shutdown(client_fd,SHUT_RD);
    printf("客户端client_fd %d 请求断开连接",client_fd);
    close(client_fd);
    free(write_buf);
    free(read_buf);
    
    return NULL;
}

int main(int argc, char const *argv[])
{
    // 定义socket的文件描述符
    int sockfd,temp_result;

    // 注册信号处理函数 SIGCHLD 正确回收所有的子进程
    signal(SIGCLD,zombie_dealer);
    
    // 定义客户端与服务端的套结字数据结构,供之后填写
    struct sockaddr_in server_addr,client_addr;
    // 清空
    memset(&server_addr,0,sizeof(server_addr));
    memset(&client_addr,0,sizeof(client_addr));

    // 填写服务段地址,指定协议
    server_addr.sin_family = AF_INET;
    // 填写ip地址 INADDR_ANY就是0.0.0.0
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 填写端口号
    server_addr.sin_port = htons(6666);
    
    // 网络编程流程介绍
    // 1. socket套结字创建,SOCK_STREAM说明使用的流传输,是TCP协议
    sockfd = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",sockfd);

    // 绑定服务端地址
    temp_result = bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    handle_error("bind",temp_result);

    // 3.服务端进入监听状态
    temp_result = listen(sockfd,128);
    handle_error("listen",temp_result);

    // 4.获取多个客户端连接
    socklen_t clientaddr_len = sizeof(client_addr);
    while (1)
    {
        int clientfd = accept(sockfd,(struct sockaddr*)&client_addr,&clientaddr_len);
        handle_error("accept",clientfd);
        pid_t pid = fork();
        if (pid < 0)
        {
            perror("fork");
        }else if(pid == 0)
        {
            // 子进程
            // 关闭不会使用sockfd
            close(sockfd);
            printf("与客户端%s %d 建立连接%d\n",inet_ntoa(client_addr.sin_addr),
            ntohs(client_addr.sin_port),clientfd);
            // 进行通讯
            read_from_client_then_write((void*)&clientfd);
            // 通讯完毕关闭文件描述符clientfd
            close(clientfd);
            exit(EXIT_SUCCESS);
        }else
        {
            // 父进程
            // 关闭调不使用的clientfd
            close(clientfd);
        }
        
    }
    
    printf("释放资源\n");
    close(sockfd);
    return 0;
}

由于这里是多连接的服务端,其主要的任务是接收来自多个客户端的数据内容,我们并不实现针对于某一个客户端的单一通讯,定义一个数据收发函数,同一处理每一条来自于不同客户端的数据。

客户端代码实现:
cpp 复制代码
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd,result) \
    if (result < 0)              \
    {                            \
        perror(cmd);             \
        return -1;               \
    }                            \
    

void *read_from_server(void* arg)
{
    int client_fd = *(int*) arg;
    char* read_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!read_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }
    
    // 接收client_fd文件描述符下的文件的内存
    // 只要能接收到数据,就正常使用,一直挂起
    while (count = recv(client_fd,read_buf,1024,0))
    {
        if (count < 0)
        {
            // 错误输出
            perror("count");
        }
        
        /* code */
        fputs(read_buf,stdout);
    }
    
    // 线程执行到这说明循环结束,客户端已经关闭了请求
    printf("服务端请求关闭\n");
    // 释放内存
    free(read_buf);

    return NULL;
}

void* write_to_server(void* arg)
{
    int client_fd = *(int*) arg;
    char* write_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!write_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }

    while (fgets(write_buf,1024,stdin) != NULL)
    {
        /*发送数据*/
        count = send(client_fd,write_buf,1024,0);
        if (count < 0)
        {
            perror("send");
        }
    }

    shutdown(client_fd,SHUT_WR);
    free(write_buf);
    return NULL;
}

int main(int argc, char const *argv[])
{
    pthread_t pid_read,pid_write;
    // 定义客户端与服务端的套结字数据结构,供之后填写
    struct sockaddr_in server_addr,client_addr;
    // 清空
    memset(&server_addr,0,sizeof(server_addr));
    memset(&client_addr,0,sizeof(client_addr));

    // 填写服务端地址,指定协议
    server_addr.sin_family = AF_INET;
    // 填写ip地址 本机地址127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 填写端口号 6666 这里端口号需要与服务端的端口号对应上
    server_addr.sin_port = htons(6666);

    // 填写客户端地址,指定协议ipv4
    client_addr.sin_family = AF_INET;
    // 填写ip地址 
    inet_pton(AF_INET,"192.168.200.131",&client_addr.sin_addr.s_addr);
    // 填写端口号
    client_addr.sin_port = htons(8888);
    
    // 客户端网络编程流程介绍
    // 1. socket套结字创建,SOCK_STREAM说明使用的流传输,是TCP协议
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",sockfd);

    // 2. 绑定地址
    // 客户端主机会自动分配端口号,这里注释掉绑定端口号,是避免多个客户端占用同一个端口
    // temp_result = bind(sockfd,(struct sockaddr*)&client_addr,sizeof(client_addr));
    // handle_error("bind",temp_result);

    // 3. 主动连接服务端
    int temp_result = connect(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    handle_error("connect",temp_result);

    printf("连接上服务端%s %d\n",inet_ntoa(server_addr.sin_addr),
    ntohs(server_addr.sin_port));

    pthread_create(&pid_read,NULL,read_from_server,&sockfd);
    pthread_create(&pid_write,NULL,write_to_server,&sockfd);

    pthread_join(pid_read,NULL);
    pthread_join(pid_write,NULL);

    printf("释放资源\n");
    close(sockfd);
    return 0;
}

客户端的实现比较简单,只需要实现对应的连接,以及相应的读写函数即可,与单个socket连接的client客户端类似。

测试:

我们在 Xshell 远程开启三个窗口进行测试一个服务端与两个客户端。

使用 gcc 命令生成了可执行性文件之后我们进行测试:

可以看到,服务端成功连接上了两个客户端,同时两个客户端的socket在服务端是不同的。

服务端可以接收到不同客户端的消息。

3.2 多进程优缺点

⭐ 优点
  • 稳定可靠,一个进程崩不会影响其他连接

  • 利用多核 CPU(每个子进程独立运行)

  • 编程简单

❗ 缺点
  • fork 开销较大(复制页表、调度成本)

  • 进程之间通信困难

  • 大规模连接不适合(建议用 epoll)

适用于:几十到几百连接的服务端。

四、多线程实现多连接服务端

相比多进程,多线程模型更轻量、内存开销更小,更适用于高并发

4.1 关键思路

每次 accept()接收到一个 client 客户端就直接创建一个对应的客户端线程函数:

cpp 复制代码
while (1) {
    connfd = accept();
    pthread_create(&tid, NULL, thread_func, (void*)connfd);
}

线程函数:

cpp 复制代码
void* thread_func(void *arg) {
    int connfd = (int)arg;
    handle(connfd);
    close(connfd);
    return NULL;
}

每个线程负责与一个客户端通信。

下面给出服务端的代码示例:

服务端代码实现:
cpp 复制代码
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd,result) \
    if (result < 0)              \
    {                            \
        perror(cmd);             \
        return -1;               \
    }                            \
    

void *read_from_client_then_write(void* arg)
{
    int client_fd = *(int*)arg;

    ssize_t count = 0,send_count = 0;
    char* read_buf = NULL;
    char* write_buf = NULL;

    read_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!read_buf)
    {
        printf("服务端读缓存创建异常 断开连接\n");
        shutdown(client_fd,SHUT_WR);
        close(client_fd);
        perror("malloc server read_buf");
        return NULL;
    }

    write_buf = malloc(sizeof(char) * 1024);
    // 判断内存是否分配成功
    if (!write_buf)
    {
        printf("服务端写缓存创建异常 断开连接\n");
        shutdown(client_fd,SHUT_WR);
        close(client_fd);
        perror("malloc server write_buf");
        return NULL;
    }

    while ((count = recv(client_fd,read_buf,1024,0)))
    {
        // recv获取失败返回-1
        if (count < 0)
        {
            perror("recv");
        }
        // 接收数据打印到控制台
        printf("receive message from client_fd %d:\n %s\n",client_fd,
        read_buf);
        // 收到数据的消息写到写缓存
        strcpy(write_buf,"received~\n");
        send_count = send(client_fd,write_buf,102,0);
        if (send_count < 0)
        {
            perror("send");
        }
    }

    // 客户端输入ctrl+d时退出循环
    // shutdown(client_fd,SHUT_RD);
    printf("客户端client_fd %d 请求断开连接",client_fd);
    close(client_fd);
    free(write_buf);
    free(read_buf);
    
    return NULL;
}

int main(int argc, char const *argv[])
{
    // 定义socket的文件描述符
    int sockfd,temp_result;
    
    // 定义客户端与服务端的套结字数据结构,供之后填写
    struct sockaddr_in server_addr,client_addr;
    // 清空
    memset(&server_addr,0,sizeof(server_addr));
    memset(&client_addr,0,sizeof(client_addr));

    // 填写服务段地址,指定协议
    server_addr.sin_family = AF_INET;
    // 填写ip地址 INADDR_ANY就是0.0.0.0
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    // 填写端口号
    server_addr.sin_port = htons(6666);
    
    // 网络编程流程介绍
    // 1. socket套结字创建,SOCK_STREAM说明使用的流传输,是TCP协议
    sockfd = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",sockfd);

    // 绑定服务端地址
    temp_result = bind(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    handle_error("bind",temp_result);

    // 3.服务端进入监听状态
    temp_result = listen(sockfd,128);
    handle_error("listen",temp_result);

    // 4.获取多个客户端连接
    socklen_t clientaddr_len = sizeof(client_addr);
    while (1)
    {
        int clientfd = accept(sockfd,(struct sockaddr*)&client_addr,&clientaddr_len);
        handle_error("accept",clientfd);
        printf("与客户端%s %d建立连接 文件描述符是%d\n",inet_ntoa(client_addr.sin_addr),
        ntohs(client_addr.sin_port),clientfd);
        // 进行通讯的线程
        pthread_t pid_read_write;
        if (pthread_create(&pid_read_write,NULL,read_from_client_then_write,(void*)&clientfd))
        {
            perror("pthread_create");
        }
        // 需要的等待线程结束,但是不能挂起
        pthread_detach(pid_read_write);
    }
    
    printf("释放资源\n");
    close(sockfd);
    return 0;
}

可以看到多线程服务端与多进程服务端的代码实现上,不同在于其 main 函数下实现逻辑不同,这里多线程采用每一 socket 对应一个 pthread 进行读写操作。其中的读写线程函数逻辑大差不差。

客户端代码实现:
cpp 复制代码
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>

#define handle_error(cmd,result) \
    if (result < 0)              \
    {                            \
        perror(cmd);             \
        return -1;               \
    }                            \
    

void *read_from_server(void* arg)
{
    int client_fd = *(int*) arg;
    char* read_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!read_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }
    
    // 接收client_fd文件描述符下的文件的内存
    // 只要能接收到数据,就正常使用,一直挂起
    while (count = recv(client_fd,read_buf,1024,0))
    {
        if (count < 0)
        {
            // 错误输出
            perror("count");
        }
        
        /* code */
        fputs(read_buf,stdout);
    }
    
    // 线程执行到这说明循环结束,客户端已经关闭了请求
    printf("服务端请求关闭\n");
    // 释放内存
    free(read_buf);

    return NULL;
}

void* write_to_server(void* arg)
{
    int client_fd = *(int*) arg;
    char* write_buf = malloc(sizeof(char) * 1024);
    ssize_t count = 0;

    if (!write_buf)
    {
        perror("malloc server read_buf");
        return NULL;
    }

    while (fgets(write_buf,1024,stdin) != NULL)
    {
        /*发送数据*/
        count = send(client_fd,write_buf,1024,0);
        if (count < 0)
        {
            perror("send");
        }
    }

    shutdown(client_fd,SHUT_WR);
    free(write_buf);
    return NULL;
}

int main(int argc, char const *argv[])
{
    pthread_t pid_read,pid_write;
    // 定义客户端与服务端的套结字数据结构,供之后填写
    struct sockaddr_in server_addr,client_addr;
    // 清空
    memset(&server_addr,0,sizeof(server_addr));
    memset(&client_addr,0,sizeof(client_addr));

    // 填写服务端地址,指定协议
    server_addr.sin_family = AF_INET;
    // 填写ip地址 本机地址127.0.0.1
    server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    // 填写端口号 6666 这里端口号需要与服务端的端口号对应上
    server_addr.sin_port = htons(6666);

    // 填写客户端地址,指定协议ipv4
    client_addr.sin_family = AF_INET;
    // 填写ip地址 
    inet_pton(AF_INET,"192.168.200.131",&client_addr.sin_addr.s_addr);
    // 填写端口号
    client_addr.sin_port = htons(8888);
    
    // 客户端网络编程流程介绍
    // 1. socket套结字创建,SOCK_STREAM说明使用的流传输,是TCP协议
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    handle_error("socket",sockfd);

    // 2. 绑定地址
    // 客户端主机会自动分配端口号,这里注释掉绑定端口号,是避免多个客户端占用同一个端口
    // temp_result = bind(sockfd,(struct sockaddr*)&client_addr,sizeof(client_addr));
    // handle_error("bind",temp_result);

    // 3. 主动连接服务端
    int temp_result = connect(sockfd,(struct sockaddr*)&server_addr,sizeof(server_addr));
    handle_error("connect",temp_result);

    printf("连接上服务端%s %d\n",inet_ntoa(server_addr.sin_addr),
    ntohs(server_addr.sin_port));

    pthread_create(&pid_read,NULL,read_from_server,&sockfd);
    pthread_create(&pid_write,NULL,write_to_server,&sockfd);

    pthread_join(pid_read,NULL);
    pthread_join(pid_write,NULL);

    printf("释放资源\n");
    close(sockfd);
    return 0;
}

客户端的代码其实与基于多进程实现的客户端基本一致的。

测试:

开启服务端

客户端连接服务端

可以看到两个客户端在服务端的socket文件描述符是不同的,同时三者之间进行通讯

4.2 多线程优缺点

⭐ 优点
  • 线程创建成本比进程低

  • 数据共享容易(同一进程)

  • 性能高

  • 更现代的大多数 TCP 服务器都用线程+epoll

❗ 缺点
  • 线程共享内存 → 容易出现竞争条件

  • 锁复杂度提升

  • 某线程崩溃可能导致整个进程崩溃

适用于:中等规模连接 / 并发较高但不至于几十万连接的服务端。

五、 多进程 vs 多线程:该如何选择?

在面试或实际开发中,对比这两种模型是有必要的。

特性 多进程 (Multi-process) 多线程 (Multi-thread)
资源开销 。每个进程有独立的地址空间,创建和销毁开销大。 。共享进程地址空间,创建快,切换快。
稳定性 。一个子进程崩了(SegFault),不会影响主进程和其他子进程。 。一个线程崩了,整个进程(包括所有其他线程)都会挂掉。
数据共享 。需要通过 IPC(管道、共享内存、消息队列)进行通信。 。全局变量、堆内存共享,但需要加锁(Mutex/Spinlock)。
适用场景 客户端连接数不多,但业务逻辑复杂、安全性要求高(如 Nginx 的 Worker 进程)。 客户端连接数较多,轻量级任务,追求高性能(如 Memcached)。

结语

从单连接到多连接,我们完成了 TCP 服务器的一次关键进化。多进程与多线程模型虽"古老",却是理解现代异步 I/O(如 epoll、IOCP)的前提。只有亲手写过 fork 和 pthread,才能真正体会到"并发"的代价与魅力。

原创不易,转载请注明出处。
点赞 + 收藏 + 关注,获取更多 Linux/网络/系统编程干货!

相关推荐
濊繵1 小时前
Linux网络--HTTP cookie 与 session
网络·网络协议·http
宇钶宇夕1 小时前
CODESYS V3.5 SP9 Patch 4详细安装说明(关闭杀毒软件)
运维·网络·自动化
CHANG_THE_WORLD2 小时前
Python容器转换与共有函数详解
网络·python·rpc
方块A2 小时前
轻量级的 HTTP 跳转服务
网络·网络协议·http
小猫挖掘机(绝版)2 小时前
在Ubuntu 20.04 部署DiffPhysDrone并在Airsim仿真完整流程
linux·ubuntu·自动驾驶·无人机·端到端
初圣魔门首席弟子2 小时前
第六章、[特殊字符] HTTP 深度进阶:报文格式 + 服务器实现(从理论到代码)
linux·网络·c++
Boop_wu2 小时前
[Java EE] 网络原理(1)
java·网络·java-ee
zl0_00_02 小时前
isctf2025 部分wp
linux·前端·javascript
qq_479875432 小时前
std::true_type {}
java·linux·服务器