【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是一样的。

相关推荐
夕泠爱吃糖3 小时前
TCP中的拥塞控制
网络·tcp/ip·智能路由器·拥塞控制
Miki Makimura3 小时前
UDP可靠性传输指南:从基础机制到KCP协议核心解析
网络·网络协议·学习·udp
不会c嘎嘎3 小时前
Linux -- 网络层
linux·运维·网络
想学全栈的菜鸟阿董3 小时前
Ubuntu Linux 入门指南
linux·运维·ubuntu
Broken Arrows3 小时前
如何在Linux服务器上部署jenkins?
linux·jenkins
Yyyy4823 小时前
Ansible Role修改IP地址与主机名
linux·服务器·php
从零开始的ops生活3 小时前
【Day 73】Linux-自动化工具-Ansible
linux·运维·服务器
黄沐阳3 小时前
网络产品报价指南--S5735系列交换机
运维·服务器·网络·智能路由器
小白64023 小时前
前端梳理体系从常问问题去完善-网络篇
前端·网络