【计算机网络】简学深悟启示录:socket编程之tcp

文章目录

1.TCP建立

由于 TCP 「有连接、可靠、面向字节流」的特性,分服务端和客户端两步实现,核心是「套接字创建 + 连接建立 + 数据收发」

  • 服务端需通过 listen() 监听端口、accept() 阻塞等待客户端连接
  • 客户端需通过 connect() 主动发起连接,完成三次握手后才能进行数据收发,这是 TCPUDP 实现的核心差异

由于代码量较多,具体可查看Gitee仓库:https://gitee.com/zhang-zhanhua-000/linux/tree/master/tcp

2.服务端

2.1 启动函数main

cpp 复制代码
#include "TcpServer.hpp"
#include <memory>

Log logger;

void Usage(const char *proc)
{
    std::cout << "\n\rUsage: " << proc << " port[1024+]\n" << std::endl;
}

int main(int argc, char *argv[]) 
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    uint16_t port = std::stoi(argv[1]);

    logger.Enable(Classfile);
    std::unique_ptr<TcpServer> svr(new TcpServer(port));
    svr->InitServer();
    svr->Start();
    return 0;
}

udp 相同,创建唯一的服务进程,但是这里需要设置将日志打印到文件中而不是屏幕,主要是因为我们要使用守护进程,后面会进行说明

2.2 服务端创建与处理

cpp 复制代码
#pragma once 

#include <iostream>
#include <string>
#include <stdlib.h>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <signal.h>
#include "log.hpp"
#include <pthread.h>
#include "ThreadPool.hpp"
#include "Task.hpp"
#include "Daemon.hpp"

const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";
const int backlog = 10;

extern Log logger;

enum
{
    UsageError = 1,
    SocketError,
    BindError,
    ListenError
};
class TcpServer;

class ThreadData
{
public:
    ThreadData(int fd, const std::string& ip, const uint16_t& port, TcpServer* t)
    : sockfd(fd)
    , clientip(ip)
    , clientport(port)
    , tsvr(t)
    {}
public:
    int sockfd;
    std::string clientip;
    uint16_t clientport;
    TcpServer* tsvr;
};

class TcpServer
{
public:
    TcpServer(const uint16_t &port, const std::string &ip = defaultip)
    : listensock_(defaultfd)
    , port_(port)
    , ip_(ip)
    {}

    void InitServer()
    {
        listensock_ = socket(AF_INET, SOCK_STREAM, 0);
        if(listensock_ < 0)
        {
            logger(Fatal, "create socket, error: %d, errstring: %s", errno, strerror(errno));
            exit(SocketError);
        }
        logger(Info, "create socket success, listensock_: %d", listensock_);

        int opt = 1;
        setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR, (const void *)&opt, sizeof(opt));

        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);
        local.sin_addr.s_addr = htonl(INADDR_ANY);

        if(bind(listensock_, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            logger(Fatal, "bind error, error: %d, errstring: %s", errno, strerror(errno));
            exit(BindError);
        }

        logger(Info, "bind socket success, listensock_: %d", listensock_);

        if(listen(listensock_, backlog) < 0)
        {
            logger(Fatal, "listen error, error: %d, errstring: %s", errno, strerror(errno));
            exit(ListenError);
        }
        logger(Info, "listen socket success, listensock_: %d", listensock_);
    }


    static void* Routine(void* args)
    {
        pthread_detach(pthread_self());
        ThreadData* td = static_cast<ThreadData*>(args);
        td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
        delete td;
        return nullptr;
    }

    void Start()
    {
        Daemon();
        //signal(SIGPIPE, SIG_IGN);
        ThreadPool<Task>::GetThreadPool()->Start();
        logger(Info, "TcpServer is running...");

        for(;;)
        {
            struct sockaddr_in client;
            socklen_t len = sizeof(client);
            int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
            if(sockfd < 0)
            {
                logger(Warning, "accept error, errno: %d, errstring: %s", errno, strerror(errno));
                continue;
            }
            uint16_t clientport = ntohs(client.sin_port);
            char clientip[32];
            inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); 

            logger(Info, "get a new link..., sockfd: %d\n", sockfd);

            // v1 --- 单进程版
            // Service(sockfd, clientip, clientport);
            // close(sockfd);

            // v2 --- 多进程版
            // pid_t id = fork();
            // if(id == 0)
            // {
            //     close(listensock_);
            //     if(fork() > 0) exit(0);// 孙子进程
            //     Service(sockfd, clientip, clientport);
            //     close(sockfd);
            //     exit(0);
            // }
            // close(sockfd);
            // pid_t rid = waitpid(id, nullptr, 0);
            // (void)rid;

            // v3 --- 多线程版
            // ThreadData* td = new ThreadData(sockfd, clientip, clientport, this);
            // pthread_t tid;
            // pthread_create(&tid, nullptr, Routine, td);

            // v4 --- 线程池版
            Task t(sockfd, clientip, clientport);
            ThreadPool<Task>::GetThreadPool()->Push(t);
        }
    }

    void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
    {
        char buffer[4096];
        while(true)
        {
            ssize_t n = read(sockfd, buffer, sizeof(buffer));
            if(n > 0)
            {
                buffer[n] = 0;
                std::cout << "client say# " << buffer << std::endl;
                std::string echo_string = "tcpserver echo# 你的信息我收到了 ";
                echo_string += buffer;

                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if(n == 0)
            {
                logger(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
                break;
            }
            else
            {
                logger(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
                break;
            }
        }
    }

    ~TcpServer()
    {}
private:
    int listensock_;
    uint16_t port_;
    std::string ip_;
};

初始化套接字不多说了,和 udp 基本差不多,sockbind,然后就是多了个 listen,由于 tcp 需要保证连接的可靠性,务端必须先 "处于监听状态",才能接收客户端的 connect() 连接请求,开启端口监听

backlog 参数表示 tcp 连接队列的最大长度,不会太大,一般设置为 5,在 tcp 协议会详细讲解

2.2.1 守护进程

cpp 复制代码
┌──────────────────────────────────────────────────────────┐
│  ┌────────────────────────────────────────────────────┐  │
│  │                  会话 Session 1                     │  │  ← 会话:进程管理单元
│  │  (会话首进程 = bash 1,SID = bash 1的PID)         │  │  绑定一个PTY,管理一组进程
│  │  ┌───────────┐  ┌───────────┐  ┌───────────┐      │  │
│  │  │  bash 1   │  │  进程A    │  │  进程B    │      │  │  ← bash:命令解释器(会话首进程)
│  │  │ (大脑)    │  │ (./app)   │  │ (top)     │      │  │  解析命令,生成子进程
│  │  └───────────┘  └───────────┘  └───────────┘      │  │
│  └────────────────────────────────────────────────────┘  │
│                                                          │
│  ┌────────────────────────────────────────────────────┐  │
│  │                  会话 Session 2                     │  │  ← 每个SSH渠道对应一个独立会话
│  │  (会话首进程 = bash 2,SID = bash 2的PID)         │  │  会话之间完全隔离
│  │  ┌───────────┐  ┌───────────┐                        │  │
│  │  │  bash 2   │  │  进程C    │                        │  │
│  │  └───────────┘  └───────────┘                        │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘

要了解守护进程,我们要知道什么是会话,你打开一个 Xshell 窗口,这叫终端,在终端里创建多个 ssh 渠道,每个渠道里可以运行很多进程或线程,这每一个 ssh 渠道就是会话,会话和会话之间是独立开来的

我们知道,任务分为前台任务和后台任务,发现前台任务运行时可以执行命令行,但是后台任务不行,这是因为前台任务只能存在一个,而且要一直存在,要么是 bash(命令行交互进程,默认前台),要么是自己运行的前台任务,而后台任务可以有很多个

创建后台进程

这个 [1] 2420845 表示序号 1 的后台任务进程 PID2420845

查看后台进程

jobs 命令查看后台进程

后台转前台&&暂停任务

fg+序列号 表示把后台任务转为前台任务,CTRL+Z 表示暂停任务

暂停的前台切换到后台继续运行

bg+序列号 执行后,作业会在后台恢复运行,终端可继续输入新命令

列名 含义(对应当前输出)
PPID 父进程ID:第一行1表示该进程的父进程是系统初始化进程(init/systemd);第二行2419975是当前终端进程的ID
PID 进程ID:第一行2428845./TcpServer的进程号;第二行2428864grep的进程号
PGID 进程组ID:第一行2428845(自身为进程组组长);第二行2428863grep所属进程组的ID
SID 会话ID:第一行2428845(自身为会话组长);第二行2419975是当前终端会话的ID
TTY 终端设备:第一行?表示该进程不关联终端(通常是后台守护进程类程序);第二行pts/6是当前伪终端
TPGID 终端进程组ID:第一行-1(无关联终端,所以无值);第二行2428863是当前终端前台进程组的ID
STAT 进程状态:第一行Ssl表示进程处于休眠(S)、是会话组长(s)、以线程方式运行(l);第二行R+表示进程处于运行状态(R)且位于前台(+)
UID 进程所属用户ID:1000是当前普通用户
TIME 进程占用CPU时间:0:00表示尚未占用有效CPU时间
COMMAND 进程启动命令:第一行是运行./TcpServer并监听8888端口;第二行是grep命令匹配"8888"

了解完以上知识点,我们就很容易理解守护进程了,实现守护进程的前提是他不能是进程组组长,setsid() 对进程组组长无效,setsid() 的作用是让调用进程脱离原会话、原进程组,创建新会话并成为新会话的首进程,同时成为新进程组的组长,且没有控制终端

cpp 复制代码
#pragma once
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

const std::string nullfile = "/dev/null";

void Daemon(const std::string& cwd = "")
{
    signal(SIGCLD, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);
    signal(SIGSTOP,SIG_IGN);

    if(fork() > 0)
    {
        exit(0);
    }
    setsid();

    if(!cwd.empty())
    {
        chdir(cwd.c_str());
    }

    int fd = open(nullfile.c_str(), O_RDWR);
    if(fd > 0)
    {
        dup2(fd, 0);
        dup2(fd, 1);
        dup2(fd, 2);
        close(fd);
    }
}

这是一个简易的守护进程创建函数,首先通过忽略SIGCLD(子进程终止信号)、SIGPIPE(管道破裂信号)、SIGSTOP(进程停止信号)避免守护进程被这些信号意外中断,接着调用fork()创建子进程并让父进程直接退出,确保子进程不是进程组组长(从而能成功调用后续的setsid()),随后setsid()创建新会话,让子进程脱离原控制终端、原进程组和原会话,成为新会话首进程和新进程组组长,实现真正后台运行

如果传入了非空的工作目录参数,会通过chdir()切换到该指定工作目录,目的是为了避免守护进程依赖原工作目录,当进程执行过后,执行文件就在内存中生成了,不必过度担心源文件是否被删除,除非是动态的

最后打开/dev/null(空设备文件,相当于一个垃圾桶,往里面扔什么都无效),并通过dup2()将标准输入(0)、标准输出(1)、标准错误(2)重定向到该空设备,关闭原文件描述符,避免守护进程后续的 I/O 操作依赖原控制终端,完成守护进程的核心创建流程

其实系统中也有提供相应的实现守护进程的函数,但是为了准确实现功能,项目中一般自行实现 daemon 函数

在命令行可以用 nohup 命令 & 启动(如nohup ./TcpServer 8888 &)守护进程

2.2.2 接收客户端信息

accept 用于接收客户端连接的,从已完成三次握手的连接队列中取出一个连接,创建新的套接字与客户端通信

  1. 监听套接字 vs 已连接套接字
    • sockfd是监听套接字(持续存在,用于接收新连接)
    • accept返回的是新套接字(仅用于与当前客户端通信,通信结束后需关闭)

就好比监听套接字是酒店前台,而接收套接字是各个房间

  1. 阻塞/非阻塞模式
    • 默认是阻塞模式:若连接队列为空,accept会阻塞直到有新连接
    • 若将监听套接字设为非阻塞(fcntl设置O_NONBLOCK),无连接时会立即返回-1并置errno=EAGAIN

2.2.3 处理客户端信息

cpp 复制代码
void Service(int sockfd, const std::string& clientip, const uint16_t& clientport)
{
    char buffer[4096];
    while(true)
    {
        ssize_t n = read(sockfd, buffer, sizeof(buffer));
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
            std::string echo_string = "tcpserver echo# 你的信息我收到了 ";
            echo_string += buffer;

            write(sockfd, echo_string.c_str(), echo_string.size());
        }
        else if(n == 0)
        {
            logger(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd);
            break;
        }
        else
        {
            logger(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);
            break;
        }
    }
}

我们将设置一个 Service 函数用于处理客户端发来的信息,同时方便清晰展示功能实现,以下是四种代码调用方式:

v1 --- 单进程版

cpp 复制代码
Service(sockfd, clientip, clientport);
close(sockfd);

accept 接收到客户端连接后,直接在主进程中调用 Service 处理通信,通信结束后关闭连接套接字 ,再循环等待下一个连接,以最简单的方式实现,但是这无法适用高并发场景,同一时间只能处理一个客户端的请求,其他客户端连接会被阻塞在 listen 的连接队列中,直到当前客户端断开连接,仅适用于测试功能场景

v2 --- 多进程版

cpp 复制代码
pid_t id = fork();
if(id == 0)
{
    close(listensock_);
    if(fork() > 0) exit(0);// 孙子进程
    Service(sockfd, clientip, clientport);
    close(sockfd);
    exit(0);
}
close(sockfd);
pid_t rid = waitpid(id, nullptr, 0);
(void)rid;

accept 成功后,主进程 fork 子进程,由子进程专门处理客户端通信(子进程中关闭监听套接字 ),主进程关闭连接套接字 并继续 accept 新连接;同时通过 "子进程再 fork 产生孙子进程" 的方式,让孙子进程成为孤儿进程被系统收养,避免主进程产生僵尸进程

注意父进程一定要关闭已经打开的文件描述符(此时孙子进程正常执行已经用不到了),不然文件描述符越堆越多,只会不够用

此时多进程真正实现了并发服务,但是进程创建的内存开销(进程地址空间、页表等)远大于线程,高并发下大量创建子进程会迅速耗尽系统内存、文件描述符等资源,导致服务器卡死,要是复杂环境下 fork 逻辑不完善还有僵尸进程的风险

v3 --- 多线程版

cpp 复制代码
class TcpServer;

class ThreadData
{
public:
    ThreadData(int fd, const std::string& ip, const uint16_t& port, TcpServer* t)
    : sockfd(fd)
    , clientip(ip)
    , clientport(port)
    , tsvr(t)
    {}
public:
    int sockfd;
    std::string clientip;
    uint16_t clientport;
    TcpServer* tsvr;
};

...

static void* Routine(void* args)
{
    pthread_detach(pthread_self());
    ThreadData* td = static_cast<ThreadData*>(args);
    td->tsvr->Service(td->sockfd, td->clientip, td->clientport);
    delete td;
    return nullptr;
}

void Start()
{
	...
		
    for(;;)
    {
    	...
    	
        ThreadData* td = new ThreadData(sockfd, clientip, clientport, this);
        pthread_t tid;
        pthread_create(&tid, nullptr, Routine, td);
    }
}

accept 成功后,创建一个新线程,将连接套接字、客户端 IP / 端口 等信息封装到 ThreadData 中传递给线程入口函数 Routine,由线程专门处理客户端通信;同时调用 pthread_detach 分离线程,避免主线程需要调用 pthread_join 回收线程资源(主要是为了提高并行效率,不要等主线程来释放,让通信完的线程自行释放,能让主线程有更多时间去创建新线程)

线程是轻量级执行流,共享进程的地址空间,创建线程的内存开销远小于进程,高并发下可以创建更多线程处理连接,资源利用率更高

但是多个线程共享进程资源,若对共享数据的访问未加锁保护,会出现数据竞争、数据混乱等问题,甚至导致程序崩溃

v4 --- 线程池版

Task.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include "log.hpp"

extern Log logger;

class Task
{
public:
    Task(int sockfd, const std::string& clientip, const uint16_t& clientport)
    : sockfd_(sockfd)
    , clientip_(clientip)
    , clientport_(clientport)
    {}

    Task()
    {}
    
    void run()
    {
        char buffer[4096];
        ssize_t n = read(sockfd_, buffer, sizeof(buffer));
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "client say# " << buffer << std::endl;
            std::string echo_string = "tcpserver echo# 你的信息我收到了 ";
            echo_string += buffer;

            n = write(sockfd_, echo_string.c_str(), echo_string.size());
            if(n < 0)
            {
                logger(Warning, "write error, sockfd: %d, client ip: %s, client port: %d", sockfd_, clientip_.c_str(), clientport_);
            }
        }
        else if(n == 0)
        {
            logger(Info, "%s:%d quit, server close sockfd: %d", clientip_.c_str(), clientport_, sockfd_);
        }
        else
        {
            logger(Warning, "read error, sockfd: %d, client ip: %s, client port: %d", sockfd_, clientip_.c_str(), clientport_);
        }
        close(sockfd_);
    }

    ~Task()
    {}
private:
    int sockfd_;
    uint16_t clientport_;
    std::string clientip_;
};

ThreadPool.hpp

这里不作具体展示,详细可查看:【Linux操作系统】简学深悟启示录:线程同步与互斥

cpp 复制代码
Task t(sockfd, clientip, clientport);
ThreadPool<Task>::GetThreadPool()->Push(t);

提前创建一个固定数量的线程组成生产消费者模型的线程池,且该线程池为单例模式,accept 成功后,将客户端连接封装为 Task 任务,推入线程池的任务队列中;线程池中的空闲线程会从任务队列中取出任务并执行通信逻辑,任务完成后线程不会退出,而是继续等待下一个任务

这个版本已经是一个很好的版本了,不仅能够高并发访问,还能解决资源共享时数据竞争的问题,线程复用避免了频繁的线程上下文切换,不会出现因大量线程切换导致的性能骤降

3.客户端

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

void Usage(const std::string &proc)
{
    std::cout << "\n\rUsage: " << proc << " serverip serverport\n"
              << std::endl;
}

int main(int argc, char *argv[])
{
    signal(SIGPIPE, SIG_IGN);
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    // server.sin_addr.s_addr = htonl(stoi(serverip));
    inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "sockfd error..." << std::endl;
        return 1;
    }

    int cnt = 5;
    bool isreconect = false;
    do
    {
        int n1 = connect(sockfd, (sockaddr *)&server, sizeof(server));
        if (n1 < 0)
        {
            std::cerr << "connect error..., reconect: " << cnt << std::endl;
            cnt--;
            isreconect = true;
            sleep(1);
        }
        else
        {
            break;
        }
    } while (cnt && isreconect);

    if (cnt == 0)
    {
        std::cerr << "user offline..." << std::endl;
        close(sockfd);
        return 1;
    }

    while (true)
    {
        std::string message;

        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        if (message.empty())
        {
            continue;
        }
        
        if (message == "quit" || message == "exit")
        {
            std::cout << "client exit..." << std::endl;
            break;
        }

        int n2 = write(sockfd, message.c_str(), message.size());
        if (n2 < 0)
        {
            std::cerr << "write error..." << std::endl;
            continue;
        }

        char inbuffer[4096];
        ssize_t n3 = read(sockfd, inbuffer, sizeof(inbuffer));
        if (n3 > 0)
        {
            inbuffer[n3] = 0;
            std::cout << inbuffer << std::endl;
        }
        else if (n3 == 0)
        {
            std::cerr << "server disconnect..." << std::endl;
            break;
        }
        else
        {
            std::cerr << "read error..." << std::endl;
            break;
        }
    }
    close(sockfd);

    return 0;
}

客户端只建立一次连接,完成多条消息的交互,并加入了重连机制,直到用户主动退出再关闭套接字

这里刻意忽略了 SIGPIPE,服务端也做了同样的处理,但作用完全不同

  • 服务端: 由于会有多个客户端不断连接断开服务端,当一个客户端断开时,只有服务端连接着管道,此时服务端的进程会被默认杀死,所以需要忽略 SIGPIPE
  • 客户端: 忽略后 write() 会返回 -1 并设置 errno=EPIPE,程序可以捕获该错误并自行处理(而非直接崩溃),让客户显式看到具体出错

希望读者们多多三连支持

小编会继续更新

你们的鼓励就是我前进的动力!

相关推荐
小李独爱秋2 小时前
计算机网络经典问题透视:端到端时延和时延抖动有什么区别?
运维·服务器·计算机网络·安全·web安全
Arwen3032 小时前
如何消除APP、软件的不安全下载提示?怎样快速申请代码签名证书?
网络·网络协议·tcp/ip·安全·php·ssl
摸鱼哉2 小时前
西电软工计网的复习
计算机网络
科技块儿2 小时前
如何通过部署IP离线库,实现批量、高速、无网络依赖的IP查询能力?
网络·网络协议·tcp/ip
txinyu的博客3 小时前
结合游戏场景解析UDP可靠性问题
java·开发语言·c++·网络协议·游戏·udp
今儿敲了吗3 小时前
计算机网络第三章笔记(四)
笔记·计算机网络
fy zs3 小时前
传输层协议UDP
网络协议·tcp/ip·udp
w陆压3 小时前
2.TCP三次握手、四次挥手
网络·网络协议·计网知识点
紫色的路3 小时前
TCP消息边界处理的精妙算法
c++·网络协议·tcp/ip·算法