【Linux网络】Socket编程:TCP网络编程

在前面的文章中,我们使用了UDP进行网络编程,这篇文章我们就来使用另一个TCP进行网络编程,我们知道UDP和TCP都是传输层协议,但是特点不同,前者无连接,不可靠传输,面向数据报,后者有连接,可靠传输,面向字节流

文章目录

  • [1. 大体框架](#1. 大体框架)
    • [1.1 补充](#1.1 补充)
    • [1.2 TcpServer.cc和TcpServer.hpp框架](#1.2 TcpServer.cc和TcpServer.hpp框架)
  • [2. 服务器初始化](#2. 服务器初始化)
    • [2.1 socket](#2.1 socket)
    • [2.2 bind](#2.2 bind)
    • [2.3 listen](#2.3 listen)
  • [3. 运行服务器](#3. 运行服务器)
    • [3.1 accept](#3.1 accept)
    • [3.2 多进程版服务器](#3.2 多进程版服务器)
    • [3.3 多线程版服务器](#3.3 多线程版服务器)
    • [3.4 线程池版服务器](#3.4 线程池版服务器)
  • [4. 实现客户端](#4. 实现客户端)

1. 大体框架

1.1 补充

首先,在之前的UDP网络编程中,我们是直接使用的硬编码,例如退出码直接就设为1、2、3等,显然这并不是一个很好的选择,那么这里我们可以统一设计一个服务器的退出码,就像之前设计日志等级一样,使用枚举常量

我们在Common.hpp文件中定义枚举常量

cpp 复制代码
enum ExitCode
{
    OK = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR
};

在后续使用中,我们就能明白这些错误码的含义

另外,我们服务器通常是不能被拷贝的,那我们可以在Common.hpp文件中定义一个不能被拷贝的基类,后面不同的服务器都可以继承这个基类来达到不能被拷贝的目的

cpp 复制代码
class NoCopy
{
public:
    NoCopy(){}
    ~NoCopy(){}
    NoCopy(const NoCopy &) = delete;
    const NoCopy &operator = (const NoCopy&) = delete;
};

1.2 TcpServer.cc和TcpServer.hpp框架

TcpServer.cc:

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


using task_t = std::function<void()>;

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " port" << std::endl;
}

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

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

    Enable_Console_Log_Strategy();

    // 网络服务器对象提供网络通信功能
    std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(port);
    tsvr->Init();
    tsvr->Run();
    return 0;
}

TcpServer.hpp:

cpp 复制代码
class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port)
        :_port(port)
    {}

    void Init()
    {}

    void Run()
    {}

    ~TcpServer() {}
private:
    uint16_t _port; // 端口号

};

2. 服务器初始化

2.1 socket

第一步肯定是创建套接字,不过与UDP不同的是第二个参数,我们套接字类型选择面向连接的可靠字节流

cpp 复制代码
const static int defaultsockfd = -1;

class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port)
        :_port(port)
        ,_sockfd(defaultsockfd)
    {}

    void Init()
    {
        // 1. 创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket success";
        
    }

    void Run()
    {}

    ~TcpServer() {}
private:
    uint16_t _port; // 端口号
    int _sockfd;
};

注意:对于使用过的系统调用我们不再详细介绍,可翻看之前UDP网络编程


2.2 bind

第二步就是绑定地址

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

我们现在不需要再自己去填写地址结构的信息,因为我们之前封装了 InetAddr 类,我们直接在 InetAddr 类中再实现一个获取地址结构的函数即可

cpp 复制代码
	const struct sockaddr* NetAddrPtr()
    {
        return &_addr;
    }

由于我们参数中地址结构的类型是 const struct sockaddr ,但是 InetAddr 类中_addr 是 const struct sockaddr_in 类型,所以我们需要类型转换一下

所以我们在Common.hpp文件中实现一个类型转换的宏

cpp 复制代码
#define CONV(addr) ((const struct sockaddr*)&addr)

下面就直接在返回时使用这个宏就可以了

cpp 复制代码
	const struct sockaddr* NetAddrPtr()
    {
        return CONV(_addr);
    }

bind中第三个参数需要知道地址结构的长度,同样 InetAddr 类中再增加一个获取地址结构长度的函数

cpp 复制代码
	socklen_t NetAddrLen()
    {
        return sizeof(_addr);
    }

另外,我们服务端需要监听服务器的所有IP,任何网络接口(网卡)发来的连接我们都愿意接受,所以我们地址结构中的成员 sin_addr 需要设置为 INADDR_ANY ,也就是IP地址此时为0,这在UDP网络编程时我们已经详细介绍了原因,这意味着我们服务端在使用 InetAddr 类时只需要传入端口号,那么我们构造函数还需要重载一个供服务端使用

cpp 复制代码
	InetAddr(uint16_t port)
        :_ip("0"), _port(port)
    {
        // 主机转网络
        memset(&_addr, 0, sizeof(_addr));
        _addr.sin_family = AF_INET;
        _addr.sin_addr.s_addr = INADDR_ANY;
        _addr.sin_port = htons(_port);
    }

那绑定地址就简单了,代码如下:

cpp 复制代码
	void Init()
    {
        // 1. 创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket success";

        // 2. 绑定地址
        InetAddr local(_port);
        int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success";

    }

2.3 listen

第三步与UDP编程不同,我们TCP在绑定之后需要listen

为什么UDP不需要listen,而TCP就需要listen呢?

最根本的区别:面向连接无连接

TCP 的工作方式:为什么需要 listen

TCP的通信过程就像打电话,有一套严格的礼仪。

  1. 服务器准备接听:服务器启动后,它不知道谁会打来电话。它需要做两件事:

    • bind: 告诉系统"我的电话号码是 XXXX",也就是绑定一个众所周知的端口。

    • listen: 拿起听筒,等待铃响。这个动作就是告诉操作系统:"我准备好了,如果有打给我的电话,请帮我接进来(放入队列)"。

  2. 客户端拨号:客户端调用 connect,这就像"拨号",发起TCP三次握手。

  3. 服务器应答:服务器的操作系统内核收到"铃响"(SYN包),完成三次握手,然后将这个已建立的连接放入一个队列中。

  4. 服务器接听:服务器调用 accept,这就像"按下接听键",从队列中取出一个已经建立的连接,开始正式通话。

listen 的核心作用就是创建那个"等待接听的电话队列"。没有 listen,即使客户端尝试连接,服务器的操作系统也不知道该如何处理这个连接请求,会直接拒绝(RST包)。

注意:这里涉及到的三次握手等,我们后面在介绍传输层TCP协议时会详细介绍这些相关内容,目前我们暂只需要学会TCP网络编程的相关系统调用,后续会慢慢介绍其它

UDP 的工作方式:为什么不需要 listen

UDP的通信过程就像寄明信片。

  1. 服务器准备接收:服务器启动后,它只需要做一件事:

    • bind: 告诉系统"我的收件地址是 XXXX",也就是绑定一个端口。这样,邮差(操作系统)才知道把寄往这个地址的明信片送给你。
  2. 客户端发送:客户端直接使用 sendto,在明信片上写好内容、收件人地址(服务器IP和端口),然后扔进邮筒。不需要事先通知服务器"我要给你寄明信片了"。

  3. 服务器接收:服务器调用 recvfrom,这就像"查看信箱",看看有没有新的明信片。它可以从任何客户端接收明信片,无需为每个客户端建立独立的"连接"。

UDP 没有"连接"的概念,因此:

  • 不需要 listen 来创建连接队列。

  • 不需要 accept 来接受一个连接。

  • 不需要 connect(在客户端,connect 在UDP中是可选的,它只是设置一个默认的目标地址,而非建立连接)。

内核层面的视角

  • TCP内核:维护着复杂的连接状态机(如 LISTEN, SYN_RCVD, ESTABLISHED)和连接队列。listen 是触发状态从 CLOSED 变为 LISTEN 的关键调用。

  • UDP内核:几乎没有状态。它只维护一个简单的接收缓冲区。当数据报到来时,它检查目标端口是否被某个套接字绑定,如果是,就将数据报放入该套接字的缓冲区。

listen系统调用

listen 系统调用将一个已绑定的套接字置于"被动监听"状态,使其能够接受来自客户端的连接请求。它本身并不接受连接,而是为后续的 accept 调用做准备,并设置连接请求队列的长度。

简单来说,它的作用是:"告诉操作系统,我这个套接字已经准备好接受连接了,如果有客户端来连接,请先把它们安排在这个队列里。"

cpp 复制代码
#include <sys/socket.h>

int listen(int sockfd, int backlog);

参数详解

  1. int sockfd
  • 含义: 套接字描述符,由 socket 系统调用返回。

  • 要求: 在调用 listen 之前,该套接字必须已经使用 bind 系统调用与一个本地地址(IP地址和端口号)关联起来。

  1. int backlog
  • 含义: 连接请求队列的最大长度。

  • 作用: 当多个客户端同时发起连接请求时,服务器可能来不及立即调用 accept 处理。操作系统内核会维护一个队列来存放这些"已完成TCP三次握手,但尚未被服务器 accept "的连接。backlog 参数就是这个队列的最大长度。

  • 细节: 这个参数的含义在历史上有些变化,但现在通常的理解是:

    • 它指的是已完成三次握手(即 ESTABLISHED 状态)、等待 accept 取走的连接的最大数目。

    • 内核中维护的队列可能实际上分为两个部分:

      • 未完成连接队列: 存放收到SYN包,但尚未完成三次握手的连接。

      • 已完成连接队列: 存放已完成三次握手的连接。

    • backlog 参数现在通常指的是已完成连接队列的大小。

  • 如何设置

    • 可以设置为 SOMAXCONN(定义在 sys/socket.h 中),让系统使用一个默认的、相对较大的合理值(在Linux上,通常可以通过 /proc/sys/net/core/somaxconn 文件查看和修改这个默认值)。

    • 对于高并发服务器,通常会将其设置为一个较大的值(如1024或更高),但最终生效的值是 min(backlog, somaxconn)。

返回值

  • 成功: 返回 0。

  • 失败: 返回 -1,并设置相应的错误码 errno。常见的错误有:

    • EBADF: sockfd 不是有效的文件描述符。

    • EINVAL: 套接字未调用 bind 进行绑定,或者该套接字不是 SOCK_STREAM 类型。

    • ENOTSOCK: sockfd 不是一个套接字描述符。

这里我们自己设置一个

cpp 复制代码
const static int backlog = 8;

代码如下:

cpp 复制代码
	void Init()
    {
        // 1. 创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket success";

        // 2. 绑定地址
        InetAddr local(_port);
        int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success";

        // 3. 设置socket状态为listen
        n = listen(_sockfd, backlog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success: " << _sockfd;
    }

3. 运行服务器

还是和之前一样使用运行标志位来表示运行状态

cpp 复制代码
const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port)
        :_port(port)
        ,_sockfd(defaultsockfd)
        ,_isrunning(false)
    {}

    void Init()
    {
        // 1. 创建套接字
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket success";

        // 2. 绑定地址
        InetAddr local(_port);
        int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success";

        // 3. 设置socket状态为listen
        n = listen(_sockfd, backlog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success: " << _sockfd;
    }

    void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {

        }
        _isrunning = false;
    }

    ~TcpServer() {}
private:
    uint16_t _port; // 端口号
    int _sockfd;
    bool _isrunning;
};

3.1 accept

accept 系统调用从已完成连接队列中取出第一个连接请求,创建一个新的套接字用于与客户端通信,并返回这个新套接字的文件描述符。

核心理解

  • listen 只是开启监听并设置队列

  • accept 才是真正接受连接并创建通信通道

  • 监听套接字继续监听,通信套接字负责数据传输

cpp 复制代码
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数详解

  1. int sockfd
  • 含义:监听套接字描述符(由 socket 创建,经过 bind 和 listen)

  • 作用:从这个套接字的已完成连接队列中获取连接

  1. struct sockaddr *addr
  • 含义:指向存放客户端地址信息的缓冲区

  • 作用:如果非NULL,系统会将连接客户端的地址信息(IP、端口)填充到这个结构中

  • 类型:通常使用 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)

  1. socklen_t *addrlen
  • 含义:输入输出参数

  • 输入:调用前应设置为 addr 指向缓冲区的大小

  • 输出:返回时被设置为实际存储的地址结构的长度

  • 重要:调用前必须初始化,否则可能得到错误结果

返回值

  • 成功:返回一个新的套接字描述符(≥0),专门用于与这个特定客户端通信

  • 失败:返回 -1,并设置相应的 errno

我们socket创建套接字不是已经返回一个 "文件描述符" 了嘛,为什么accept也会返回一个 "文件描述符" 呢?两者有什么区别吗?

想象一个银行:

  • socket() 创建的描述符 = 银行大门

  • accept() 返回的描述符 = 柜台窗口

银行大门(监听套接字)

  • 只有一个,整个银行只有一个主入口

  • 作用:让客户进入银行大厅排队

  • 不办理业务:大门本身不处理存款、取款

  • 长期存在:银行营业期间一直敞开

柜台窗口(连接套接字)

  • 有多个,可以同时服务多个客户

  • 作用:专门为特定客户办理具体业务

  • 临时性:客户办完业务就关闭窗口

  • 一对一服务:每个窗口只服务一个客户

技术层面

  1. 监听套接字(由 socket() 返回)‍
  • 作用:用于监听客户端的连接请求,不直接传输数据
  • 生命周期:在服务器整个运行期间通常保持打开状态,持续接受新连接
  • 状态:处于 LISTEN 状态,等待连接请求
  1. 连接套接字(由 accept() 返回)‍
  • 作用:代表与特定客户端的已建立连接,用于实际的数据读写
  • 生命周期:仅在对应客户端连接期间存在,连接关闭后该描述符失效
  • 状态:处于 ESTABLISHED 状态,可直接进行 I/O 操作

关键区别

特性 socket() 返回的描述符 accept() 返回的描述符
角色 监听器(接受新连接) 连接端点(与客户端通信)
数量 通常一个 每个客户端连接一个
数据传递 不直接传输数据 直接读写数据
关联对象 服务器地址和端口 特定客户端的 IP 和端口

为什么需要两个描述符?

这种设计实现了并发处理:服务器用一个监听描述符持续接受新请求,同时为每个已连接客户端创建独立的描述符处理数据交换,互不干扰。

所以socket返回的是监听套接字,那我们可以将成员变量_sockfd修改为_listensockfd增加代码可读性,然后accept接受客户端的连接

代码如下:

cpp 复制代码
const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port)
        :_port(port)
        ,_listensockfd(defaultsockfd)
        ,_isrunning(false)
    {}

    void Init()
    {
        // 1. 创建套接字
        _listensockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket success";

        // 2. 绑定地址
        InetAddr local(_port);
        int n = bind(_listensockfd, local.NetAddrPtr(), local.NetAddrLen());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success";

        // 3. 设置socket状态为listen
        n = listen(_listensockfd, backlog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success: " << _listensockfd;
    }

    void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 如果没有连接,accept就会阻塞
            int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errpr";
                continue;
            }
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();
        }
        _isrunning = false;
    }

    ~TcpServer() {}
private:
    uint16_t _port; // 端口号
    int _listensockfd;
    bool _isrunning;
};

接受客户端的连接后,就可以对客户端发送给服务端的数据进行处理,我们可以先来写一个简单的EchoServer服务

和UDP一样的步骤,我们需要先读取客户端发送的数据,然后再写回,因为tcp已经和客户端建立好连接了,所以不需要和UDP一样每次收发数据都需要完整的地址信息,而且tcp和文件操作一样,都是面向字节流的,所以我们可以使用read/write来读写数据

cpp 复制代码
	void Service(int sockfd, InetAddr& addr)
    {
        char buffer[1024];
        while(true)
        {
            // 1. 读取数据
            // a. n>0: 读取成功
            // b. n<0: 读取失败
            // c. n==0: 对端把链接关闭了,读到了文件的结尾
            ssize_t n = read(sockfd, buffer, sizeof(buffer)-1);
            if(n > 0)
            {
                buffer[n] = 0;
                LOG(LogLevel::DEBUG) << addr.StringAddr() << "# " << buffer; 
                
                // 2. 写回数据
                std::string echo_string = "echo# ";
                echo_string += buffer;
                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if(n == 0)
            {
                LOG(LogLevel::DEBUG) << addr.StringAddr() << "退出了...";
                close(sockfd);
                break;
            }
            else
            {
                LOG(LogLevel::DEBUG) << addr.StringAddr() << "异常...";
                close(sockfd);
                break;
            }
        }
    }

    void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 如果没有连接,accept就会阻塞
            int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errpr";
                continue;
            }
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();

            // v0------EchoServer
            Service(sockfd, addr);
        }
        _isrunning = false;
    }

但是这里有个问题,当一个客户与服务端连接后进行读写数据时,此时服务端就会执行Service函数,但是这个时候如果再来一个或多个客户与服务端进行连接时,服务端是不能accept连接客户端的,因为服务端是单进程在执行Service函数,也就是只要当前客户与服务端的连接没有断开,那么服务端就会一直死循环进行收发数据,所以其他客户就不能与服务端建立连接

那要怎么做呢?


3.2 多进程版服务器

我们可以使用多进程,创建一个子进程去执行任务,父进程则不断与客户端建立连接

问题1:进程如果退出了,曾经打开的文件会怎么办?

默认会被自动释放掉,fd,会自动被关闭,close(fd)

问题2:进程如果打开了一个文件,得到了一个fd,如果在创建子进程,这个子进程能拿到父进程的fd进行访问吗?

能,之前学习的管道不就是吗,fork创建子进程,然后分别关闭父子进程的读写端,这不就是子进程拿到了父进程的fd来进行访问吗

cpp 复制代码
	void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 如果没有连接,accept就会阻塞
            int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errpr";
                continue;
            }
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();

            // v0------EchoServer
            // Service(sockfd, addr);

            // v1------EchoServer 多进程版
            pid_t id = fork();
            if(id < 0)
            {
                LOG(LogLevel::FATAL) << "fork error";
                exit(FORK_ERR);
            }
            else if(id == 0)
            {
                // 子进程
                // 我们不想让子进程访问listensock!
                close(_listensockfd);
                Service(sockfd, addr);
                exit(OK);
            }
            else
            {
                // 父进程
                close(sockfd);
                //父进程是不是要等待子进程啊,要不然僵尸了??
                pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?
                (void)rid;
            }
        }
        _isrunning = false;
    }

这里我们父进程需要等待子进程退出,要不然子进程会成为僵尸进程,可是我们这里是阻塞等待啊,那服务端还是不能去连接其他客户啊,这怎么办?

首先,我们在学习信号时,提到过子进程退出时会给父进程发送 SIGCHLD 信号,父进程可以通过捕获此信号来调用wait/waitpid回收子进程。

那这里推荐做法就是父进程可以显式忽略该信号,这样父进程就可以继续执行自己的任务,完全不需要调用wait/waitpid,因为OS内核会自己清理回收子进程资源

这里还有另一个推荐的做法,就是子进程再次fork创建子进程,然后子进程立即退出,留下孙子进程来执行任务,孙子进程此时会成为孤儿进程,由操作系统管理回收,那么父进程就不会再阻塞了,因为子进程已经退出了

cpp 复制代码
	void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 如果没有连接,accept就会阻塞
            int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errpr";
                continue;
            }
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();

            // v0------EchoServer
            // Service(sockfd, addr);

            // v1------EchoServer 多进程版
            pid_t id = fork();
            if(id < 0)
            {
                LOG(LogLevel::FATAL) << "fork error";
                exit(FORK_ERR);
            }
            else if(id == 0)
            {
                // 子进程
                // 我们不想让子进程访问listensock!
                close(_listensockfd);
                if(fork() > 0) // 再次fork,子进程直接退出
                    exit(OK);
                Service(sockfd, addr); // 孙子进程来执行任务,但是孙子进程会成为孤儿进程,由系统来回收
                exit(OK);
            }
            else
            {
                // 父进程
                close(sockfd);
                //父进程是不是要等待子进程啊,要不然僵尸了??
                pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了
                (void)rid;
            }
        }
        _isrunning = false;
    }

3.3 多线程版服务器

那除了多进程,我们当然还可以使用多线程了,这里我们先使用原生的线程来实现多线程版

代码如下:

cpp 复制代码
	class ThreadData
    {
    public:
        ThreadData(int fd, InetAddr &addr, TcpServer *tsvr) 
            :_sockfd(fd), _addr(addr), _tsvr(tsvr)
        {
        }
    public:
        int _sockfd;
        InetAddr _addr;
        TcpServer *_tsvr;
    };

    static void* Routine(void* args)
    {
        pthread_detach(pthread_self());
        ThreadData* td = static_cast<ThreadData*>(args);
        td->_tsvr->Service(td->_sockfd, td->_addr);
        delete td;
        return nullptr;
    }

    void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 如果没有连接,accept就会阻塞
            int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errpr";
                continue;
            }
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();

            // v0------EchoServer
            // Service(sockfd, addr);

            // v1------EchoServer 多进程版
            // pid_t id = fork();
            // if(id < 0)
            // {
            //     LOG(LogLevel::FATAL) << "fork error";
            //     exit(FORK_ERR);
            // }
            // else if(id == 0)
            // {
            //     // 子进程
            //     // 我们不想让子进程访问listensock!
            //     close(_listensockfd);
            //     if(fork() > 0) // 再次fork,子进程直接退出
            //         exit(OK);
            //     Service(sockfd, addr); // 孙子进程来执行任务,但是孙子进程会成为孤儿进程,由系统来回收
            //     exit(OK);
            // }
            // else
            // {
            //     // 父进程
            //     close(sockfd);
            //     //父进程是不是要等待子进程啊,要不然僵尸了??
            //     pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了
            //     (void)rid;
            // }

            // v2------EchoServer 多线程版
            ThreadData* td = new ThreadData(sockfd, addr, this);
            pthread_t tid;
            pthread_create(&tid, nullptr, Routine, td);
        }
        _isrunning = false;
    }

这里类内成员函数隐含this指针,所以要使用静态成员函数,但静态成员函数不能访问非静态成员变量,所以我们使用了一个内部类 ThreadData ,并且传入this指针,方便我们使用类内成员变量,这些我们在学习线程时也介绍过,就不再多做解释

同时我们线程也需要等待,那要等待的话不就又会阻塞在这里了,所以我们在创建线程时就分离线程,就不需要等待线程了


3.4 线程池版服务器

说到多线程,当然就能想到线程池,所以我们还可以实现一个线程池版

cpp 复制代码
#pragma once

#include "Log.hpp"
#include "InetAddr.hpp"
#include "ThreadPool.hpp"
#include "Common.hpp"
#include <sys/wait.h>
#include <pthread.h>

using namespace LogModule;
using namespace ThreadPoolModule;

using task_t = std::function<void()>;

const static int defaultsockfd = -1;
const static int backlog = 8;
class TcpServer : public NoCopy
{
public:
    TcpServer(uint16_t port)
        :_port(port)
        ,_listensockfd(defaultsockfd)
        ,_isrunning(false)
    {}

    void Init()
    {
        // 1. 创建套接字
        _listensockfd = socket(AF_INET, SOCK_STREAM, 0);
        if(_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "socket success";

        // 2. 绑定地址
        InetAddr local(_port);
        int n = bind(_listensockfd, local.NetAddrPtr(), local.NetAddrLen());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success";

        // 3. 设置socket状态为listen
        n = listen(_listensockfd, backlog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success: " << _listensockfd;
    }

    void Service(int sockfd, InetAddr& addr)
    {
        char buffer[1024];
        while(true)
        {
            // 1. 读取数据
            // a. n>0: 读取成功
            // b. n<0: 读取失败
            // c. n==0: 对端把链接关闭了,读到了文件的结尾
            ssize_t n = read(sockfd, buffer, sizeof(buffer)-1);
            if(n > 0)
            {
                buffer[n] = 0;
                LOG(LogLevel::DEBUG) << addr.StringAddr() << "# " << buffer; 
                
                // 2. 写回数据
                std::string echo_string = "echo# ";
                echo_string += buffer;
                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if(n == 0)
            {
                LOG(LogLevel::DEBUG) << addr.StringAddr() << "退出了...";
                close(sockfd);
                break;
            }
            else
            {
                LOG(LogLevel::DEBUG) << addr.StringAddr() << "异常...";
                close(sockfd);
                break;
            }
        }
    }


    class ThreadData
    {
    public:
        ThreadData(int fd, InetAddr &addr, TcpServer *tsvr) 
            :_sockfd(fd), _addr(addr), _tsvr(tsvr)
        {
        }
    public:
        int _sockfd;
        InetAddr _addr;
        TcpServer *_tsvr;
    };

    static void* Routine(void* args)
    {
        pthread_detach(pthread_self());
        ThreadData* td = static_cast<ThreadData*>(args);
        td->_tsvr->Service(td->_sockfd, td->_addr);
        delete td;
        return nullptr;
    }

    void Run()
    {
        _isrunning = true;
        while(_isrunning)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 如果没有连接,accept就会阻塞
            int sockfd = accept(_listensockfd, (struct sockaddr*)&peer, &len);
            if(sockfd < 0)
            {
                LOG(LogLevel::WARNING) << "accept errpr";
                continue;
            }
            InetAddr addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr : " << addr.StringAddr();

            // v0------EchoServer
            // Service(sockfd, addr);

            // v1------EchoServer 多进程版
            // pid_t id = fork();
            // if(id < 0)
            // {
            //     LOG(LogLevel::FATAL) << "fork error";
            //     exit(FORK_ERR);
            // }
            // else if(id == 0)
            // {
            //     // 子进程
            //     // 我们不想让子进程访问listensock!
            //     close(_listensockfd);
            //     if(fork() > 0) // 再次fork,子进程直接退出
            //         exit(OK);
            //     Service(sockfd, addr); // 孙子进程来执行任务,但是孙子进程会成为孤儿进程,由系统来回收
            //     exit(OK);
            // }
            // else
            // {
            //     // 父进程
            //     close(sockfd);
            //     //父进程是不是要等待子进程啊,要不然僵尸了??
            //     pid_t rid = waitpid(id, nullptr, 0); // 阻塞的吗?不会,因为子进程立马退出了
            //     (void)rid;
            // }

            // v2------EchoServer 多线程版
            // ThreadData* td = new ThreadData(sockfd, addr, this);
            // pthread_t tid;
            // pthread_create(&tid, nullptr, Routine, td);

            // v3------EchoServer 线程池版
            ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){
                this->Service(sockfd, addr);
            });
        }
        _isrunning = false;
    }

    ~TcpServer() {}
private:
    uint16_t _port; // 端口号
    int _listensockfd;
    bool _isrunning;
};

注意:我们线程池是固定5个线程,所以我们最多只有五个客户端与服务端进行连接

这是因为我们Service函数是在死循环执行,这种我们可以称之为长服务,与之对应,那肯定就还有短服务,什么意思呢?下面我们来认识一下短服务和长服务

  • 短服务​:其核心是"按需连接 "。每次数据交互都遵循"建立连接 → 传输数据 → 关闭连接"的流程。就像你去银行柜台,每办理一项业务都需要重新排队一次。这种方式的好处是服务端无需长期维护大量连接状态,结构简单;缺点是频繁的"三次握手"和"四次挥手"会带来额外的网络开销和延迟。HTTP/1.0是短服务的典型代表。

  • ​长服务​:其核心是"连接复用"。一旦连接建立,就会在一定时间内保持开启,期间可以进行多次数据传输,通常还会引入心跳机制​(定期发送小数据包)来检测连接的活性。这就像一次排队后,可以连续办理多项业务,效率更高。这种方式显著减少了重复建立连接的开销,适合实时应用;但需要服务端投入更多资源来管理和维持这些长连接。

一般多进程多线程比较适合长服务,线程池适合短服务,但也不是绝对的,这里我们只简单提一下,当然也可以将死循环改一下,那客户端数量就不会仅限于5个了,感兴趣可以自己下来尝试一下


4. 实现客户端

客户端实现其实和udp网络编程时大差不差

代码如下:

cpp 复制代码
#include "Common.hpp"
#include "InetAddr.hpp"

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}

// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{
    // 客户端需要绑定服务器的ip和port
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        exit(SOCKET_ERR);
    }
    // 2. bind吗??需要。显式的bind?不需要!随机方式选择端口号
    // 2. 我应该做什么呢?listen?accept?都不需要!!
    // 2. 直接向目标服务器发起建立连接的请求

    

   
    return 0;
}

我们这里也不需要显式bind,关于原因我们在udp网络编程时已经说明了,那我们应该做什么呢?需要和服务端一样listen或者accept吗?不需要,因为服务端就相当于是接电话的人,所以服务端需要监听和接受连接,而我们客户端则相当于打电话的人,那肯定是需要向服务端发起连接,即需要connect

connect系统调用详解

connect 系统调用用于客户端向服务器发起连接请求(TCP)或设置默认对端地址(UDP)。

核心作用

  • TCP:发起三次握手,建立可靠连接

  • UDP:设置默认目标地址,之后可用 read/write

cpp 复制代码
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数详解

  1. int sockfd
  • 套接字描述符,由 socket() 创建

  • 对于TCP必须是 SOCK_STREAM 类型

  • 对于UDP必须是 SOCK_DGRAM 类型

  1. const struct sockaddr *addr
  • 指向服务器地址结构体的指针

  • 包含服务器IP地址和端口号

  • 通常是 struct sockaddr_in(IPv4)或 struct sockaddr_in6(IPv6)

  1. socklen_t addrlen
  • 地址结构体的长度

  • 例如:sizeof(struct sockaddr_in)

返回值

  • 成功:返回 0

  • 失败:返回 -1,并设置相应的 errno

代码如下:

cpp 复制代码
#include "Common.hpp"
#include "InetAddr.hpp"


void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}

// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{
    // 客户端需要绑定服务器的ip和port
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        exit(SOCKET_ERR);
    }
    // 2. bind吗??需要。显式的bind?不需要!随机方式选择端口号
    // 2. 我应该做什么呢?listen?accept?都不需要!!
    // 2. 直接向目标服务器发起建立连接的请求

    InetAddr serveraddr(server_ip, server_port);
    int n = connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NetAddrLen());
    if(n < 0)
    {
        std::cerr << "connect error" << std::endl;
        exit(CONNECT_ERR);
    }

    // 3. echo client
    while(true)
    {
        // 从键盘获取输入
        std::string line;
        std::cout << "Please Enter# ";
        std::getline(std::cin, line);

        write(sockfd, line.c_str(), line.size());

        char buffer[1024];
        ssize_t size = read(sockfd, buffer, sizeof(buffer)-1);
        if(size > 0)
        {
            buffer[size] = 0;
            std::cout << "sercer echo# " << buffer << std::endl;
        }
    }
   
    return 0;
}

运行结果:

我们网络服务已经完成了,上层服务我们可以和UDP网络编程一样,直接在服务端主程序调用其他的服务,就比如之前实现的翻译和路由转发,然后在服务端接收数据时,将数据回调处理,最后将结果写回客户端。不过这里我们就不实现了,因为和UDP是一样的。

相关推荐
|华|几秒前
GFS 分布式文件系统
linux
数智工坊3 分钟前
R-CNN目标检测算法精读全解
网络·人工智能·深度学习·算法·目标检测·r语言·cnn
AALoveTouch11 分钟前
某麦网抢票:基于Wireshark协议分析
网络·测试工具·wireshark
positive_zpc14 分钟前
计算机网络——网络层(三)
网络·计算机网络
yyk的萌14 分钟前
Claude Code 命令大全
linux·运维·服务器·ai·claude code
Fanfanaas18 分钟前
Linux 系统编程 进程篇(五)
linux·服务器·c语言·网络·学习·进程
南湖北漠21 分钟前
避免电子设备的电磁波干扰和电磁波互相干扰对我们生活的危害
网络·人工智能·计算机网络·其他·安全·生活
科技峰行者26 分钟前
解析OpenClaw安全挑战及应对策略 构筑AI Agent安全新边界
网络·人工智能·科技·安全·aws·亚马逊·亚马逊云科技
bukeyiwanshui32 分钟前
20260422 Keepalvied 高可用技术实践
网络
代码论斤卖33 分钟前
OpenHarmony teecd频繁崩溃问题分析
linux·harmonyos