主从 Reactor 高并发服务器实战 (三):服务器套接字Socket模块详解与实现

系列文章目录

文章目录

  • 系列文章目录
  • 前言
  • [一、Socket 模块](#一、Socket 模块)
    • [1.1 存在意义](#1.1 存在意义)
    • [1.2 实现功能](#1.2 实现功能)
    • [1.3 功能设计](#1.3 功能设计)
  • 二、代码实现
    • [2.1 成员变量](#2.1 成员变量)
    • [2.2 成员函数](#2.2 成员函数)
      • [(1) 初始化与销毁](#(1) 初始化与销毁)
      • [(2) 服务端相关](#(2) 服务端相关)
      • [(3) 客户端相关](#(3) 客户端相关)
      • [(4) 数据收发](#(4) 数据收发)
      • [(5) 其他](#(5) 其他)
      • [(6) 完整代码](#(6) 完整代码)
    • [2.3 三个容易写错的地方](#2.3 三个容易写错的地方)
      • [(1) `ReuseAddress()` 必须在 `bind()` 之前](#(1) ReuseAddress() 必须在 bind() 之前)
      • [(2) 设置非阻塞用的是 `F_SETFL`,不是 `F_SETFD`](#(2) 设置非阻塞用的是 F_SETFL,不是 F_SETFD)
      • [(3) 监听地址默认别用 `127.0.0.1`](#(3) 监听地址默认别用 127.0.0.1)
  • 三、测试
  • 四、总结

前言

  大家好,我是曦夜日长。上一节我们完成了 Buffer 模块,实现了用户级缓冲区。作为一个服务器项目,Socket 相关的模块一定必不可少------毕竟所有的数据最终都要从 fd 上读出来、再写回去。本节我们来实现 Socket 模块的开发。

  本节的目标很朴素:把 socket → bind → listen → accept 这四步系统调用,收进一个类里 ,让后面的模块只需要拿到一个 int fd 和几个语义明确的函数,而不用再关心 sockaddr_in 怎么写、htons 要不要转、错误码怎么判。


一、Socket 模块

1.1 存在意义

  以前的学习我们知道,想要完成 TCP 通信,需要经过 socket(创建套接字) -> bind(绑定套接字) -> listen(监听套接字) -> accept(接受连接) 这一个过程。如果面向过程地一个一个调用,代码会长这样:

cpp 复制代码
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd < 0) { /* 错误处理 */ }

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
inet_pton(AF_INET, "0.0.0.0", &addr.sin_addr);

if (bind(listenfd, (sockaddr*)&addr, sizeof(addr)) < 0) { /* 错误处理 */ }
if (listen(listenfd, 1024) < 0) { /* 错误处理 */ }

  问题有三个:

  1. 样板代码重复:每写一个服务端就要抄一遍地址结构体的填充;
  2. 错误处理分散 :socket、bind、listen 三处都要判 -1,最后往往漏掉;
  3. 资源容易泄漏 :fd 是裸的整数,没人负责在合适的时机 close。

  总结起来,Socket 模块主要解决一件事:对服务器套接字使用的简化。把"创建并配置一个能用的套接字"这件事收敛成一个函数,把"关掉它"收敛成另一个函数,剩下的细节全部藏进类里。

1.2 实现功能

  本质目的:实现通信套接字封装,简化套接字的使用。具体包含:

  1. 对套接字初始化的系列函数的封装(Create / Close,并借析构做 RAII)
  2. TCP 服务端函数的实现(Bind / Listen / Accept,以及一步到位的 CreateServer)
  3. TCP 客户端函数的实现(Connect / CreateClient)
  4. 数据收发函数的实现(Recv / Send 及非阻塞版本)
  5. 套接字属性的设置(ReuseAddress / NonBlock)
  6. 描述符的对外获取(Fd,供 Poller 注册用)

1.3 功能设计

  作为套接字,就必须关心 sockfd 这一描述符,因此就需要变量保存描述符 _sockfd。

  设计上把握三点:

  • 只保存一个 int:Socket 不持有缓冲区、不持有事件,它就只管"这个 fd 怎么来的、怎么用、什么时候关";
  • 无效状态用 -1 表示 :_sockfd == -1 就是"没有套接字",Close() 之后也置回 -1,避免二次 close 一个已经被复用的 fd 号;
  • 构造即接管 :Socket(int fd = -1) 既可以用默认构造创建一个空对象再 Create(),也可以直接接管 accept() 返回的 fd(后面 Connection 模块会大量用到这一点)。

二、代码实现

2.1 成员变量

  套接字维护一个 sockfd,这个文件描述符即可。

cpp 复制代码
private:
    int _sockfd;

  socket() 返回的描述符本质上是内核文件描述符表中的下标,是个非负整数;失败时返回 -1。所以成员只需要一个 int,容器类(vector、string)一个都不需要。

2.2 成员函数

  共 17 个成员函数(含构造与析构),按职责分成 5 组:

分组 函数 返回值 作用
初始化与销毁 Socket(int fd = -1) --- 构造,保存或接管 fd
~Socket() --- 析构时自动 Close()
Create() bool 创建套接字
Close() void 关闭套接字
服务端 Bind(ip, port) bool 绑定地址
Listen(backlog) bool 开始监听
Accept() int 接受连接,返回新 fd
CreateServer(port, ip) bool 一步创建监听套接字
属性设置 ReuseAddress() void 地址/端口复用
NonBlock() void 设置为非阻塞
客户端 Connect(ip, port) bool 发起连接
CreateClient(ip, port) bool 一步创建客户端套接字
数据收发 Recv(buf, len, flag) ssize_t 接收数据
Send(buf, len, flag) ssize_t 发送数据
NonBlockRecv(buf, len) ssize_t 非阻塞接收
NonBlockSend(buf, len) ssize_t 非阻塞发送
其他 Fd() int 获取描述符

(1) 初始化与销毁

返回值 函数名 作用 备注
bool Create() 创建一个套接字 错误返回 false
void Close() 关闭套接字 遇到异常或正常调用的最终接口

  通过两个封装后的函数直接管理套接字的创建与销毁。

cpp 复制代码
bool Create()
{
    _sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (_sockfd < 0)
    {
        LOG(LogLevel::FATAL) << "socket create error";
        return false;
    }
    return true;
}

void Close()
{
    if (_sockfd != -1)
    {
        close(_sockfd);
        _sockfd = -1;      // 关键:置为 -1,杜绝二次 close
    }
}

  Close() 里那个 _sockfd = -1 很容易被忽略,但它是防二次关闭 的关键。如果不置 -1,同一个 Socket 对象析构时还会再 close 一次------而此时这个 fd 号可能已经被新连接复用了,于是你关掉的是别人的连接。

  析构函数顺手调用 Close(),这就是最朴素的 RAII:

cpp 复制代码
~Socket() { Close(); }

  这样一来,谁持有 Socket 对象,谁就不需要关心关闭时机------对象生命周期结束,fd 自然被回收。

(2) 服务端相关

  Bind:把套接字和"IP + 端口"绑定起来。

cpp 复制代码
bool Bind(const std::string &ip, uint16_t port)
{
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));      // 必须清零,否则 sin_zero 等字段是脏值
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);         // 主机字节序 -> 网络字节序
    inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);   // 字符串 IP -> 二进制

    int n = bind(_sockfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_in));
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "bind error";
        return false;
    }
    return true;
}

  这里有两个细节:htons 不能省(x86 是小端,网络字节序是大端);memset 也不能省,sockaddr_in 里有个 sin_zero 填充字段,不清零可能被内核当成脏数据。

  Listen:把主动套接字变成被动套接字,开始接收连接。

cpp 复制代码
bool Listen(int backlog = defaultlog)
{
    int n = listen(_sockfd, backlog);
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "listen error";
        return false;
    }
    return true;
}

  backlog 是已完成连接队列 的最大长度(内核里还有个半连接队列,长度受 somaxconn 影响)。

  Accept:从已完成连接队列里取出一个连接。

cpp 复制代码
int Accept()
{
    int newfd = accept(_sockfd, nullptr, nullptr);
    if (newfd < 0)
    {
        // 非阻塞监听套接字上,没有新连接时返回 EAGAIN,这是正常情况,不是错误
        if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
            return -1;
        LOG(LogLevel::ERROR) << "accept error: " << strerror(errno);
        return -1;
    }
    return newfd;
}

  注意 accept 返回的是一个新的 fd ,它才是真正用来通信的;监听 fd 继续留着等下一个连接。很多初学者第一次写的时候会拿监听 fd 去 recv,结果一直读不到数据,就是这里搞混了。

  另外,如果监听套接字是非阻塞的(我们后面会设),那么"队列空了"时 accept 返回 -1 且 errno == EAGAIN。这属于正常的空轮询,不能当错误打日志,否则日志会被刷屏。

  CreateServer:把上面几步串起来,一步创建监听套接字。

cpp 复制代码
bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0")
{
    if (Create() == false)
        return false;

    ReuseAddress();      // ★ 必须在 bind 之前
    NonBlock();          // 监听 socket 设为非阻塞

    if (Bind(ip, port) == false)
        return false;
    if (Listen() == false)
        return false;

    return true;
}

(3) 客户端相关

cpp 复制代码
bool Connect(const std::string &ip, uint16_t port)
{
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);

    int n = connect(_sockfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_in));
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "connect error";
        return false;
    }
    return true;
}

bool CreateClient(const std::string &ip, uint16_t port)
{
    if (Create() == false)
        return false;
    if (Connect(ip, port) == false)
        return false;
    return true;
}

  客户端这边简单得多:socket + connect 两步。Connect 里没有 bind,因为内核会自动帮我们选一个临时端口并完成绑定------这叫做隐式绑定。

(4) 数据收发

cpp 复制代码
ssize_t Recv(void *buf, size_t len, int flag = 0)
{
    ssize_t ret = recv(_sockfd, buf, len, flag);
    if (ret > 0)
        return ret;                       // 实际读到的字节数

    if (ret == 0)
        return -1;                        // 对端关闭(收到 FIN)

    if (errno == EAGAIN || errno == EINTR)
        return 0;                         // 没数据 / 被信号打断

    LOG(LogLevel::ERROR) << "socket recv error";
    return -1;
}

ssize_t Send(const void *buf, size_t len, int flag = 0)
{
    ssize_t ret = send(_sockfd, buf, len, flag);
    if (ret >= 0)
        return ret;                       // 实际发出去的字节数(可能小于 len)

    if (errno == EAGAIN || errno == EINTR)
        return 0;                         // 内核发送缓冲区满了

    LOG(LogLevel::ERROR) << "socket send error";
    return -1;
}

  这两个函数的返回值需要记住三种含义(后面写 Reactor 时天天跟它打交道):

返回值 Recv 含义 Send 含义
> 0 读到的字节数(可能小于 len) 发出去的字节数(可能小于 len)
0 这次没读到(EAGAIN / EINTR) 内核缓冲区满,一个字节都没发出去
-1 出错,或对端已关闭 出错

  这里要特别强调 Send 的返回值:send 不保证一次把 len 字节全部发完 。所以在真正的服务器里,发送一定要配一个写缓冲区,把没发完的部分存下来、注册 EPOLLOUT、下次可写时接着发------这正是后面 Connection 模块要做的事。

  非阻塞版本只是多带了一个 MSG_DONTWAIT 标志,不需要改动套接字本身的属性:

cpp 复制代码
ssize_t NonBlockRecv(void *buf, size_t len)
{
    return Recv(buf, len, MSG_DONTWAIT);
}

ssize_t NonBlockSend(void *buf, size_t len)
{
    return Send(buf, len, MSG_DONTWAIT);
}

(5) 其他

cpp 复制代码
int Fd() { return _sockfd; }

  就一行,但后面 Poller 注册 epoll、Channel 保存 fd 都要靠它。只暴露值,不暴露引用 ,避免外部把 _sockfd 改坏。

(6) 完整代码

cpp 复制代码
//网络套接字的基础配置
const int defaultlog = 1024;

class Socket
{
public:
    Socket(int fd = -1): _sockfd(fd)
    {}
    ~Socket() { Close(); }

    /* ---------------- 初始化与销毁 ---------------- */
    bool Create()
    {
        _sockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "socket create error";
            return false;
        }
        return true;
    }

    void Close()
    {
        if (_sockfd != -1)
        {
            close(_sockfd);
            _sockfd = -1;
        }
    }

    /* ---------------- 服务端 ---------------- */
    bool Bind(const std::string &ip, uint16_t port)
    {
        struct sockaddr_in addr;
        memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);

        int n = bind(_sockfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_in));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            return false;
        }
        return true;
    }

    bool Listen(int backlog = defaultlog)
    {
        int n = listen(_sockfd, backlog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            return false;
        }
        return true;
    }

    int Accept()
    {
        int newfd = accept(_sockfd, nullptr, nullptr);
        if (newfd < 0)
        {
            if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)
                return -1;
            LOG(LogLevel::ERROR) << "accept error: " << strerror(errno);
            return -1;
        }
        return newfd;
    }

    bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0")
    {
        if (Create() == false)
            return false;

        ReuseAddress();     // 必须在 bind 之前
        NonBlock();

        if (Bind(ip, port) == false)
            return false;
        if (Listen() == false)
            return false;
        return true;
    }

    /* ---------------- 客户端 ---------------- */
    bool Connect(const std::string &ip, uint16_t port)
    {
        struct sockaddr_in addr;
        memset(&addr, 0, sizeof(addr));
        addr.sin_family = AF_INET;
        addr.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &addr.sin_addr);

        int n = connect(_sockfd, (struct sockaddr *)&addr, sizeof(struct sockaddr_in));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "connect error";
            return false;
        }
        return true;
    }

    bool CreateClient(const std::string &ip, uint16_t port)
    {
        if (Create() == false)
            return false;
        if (Connect(ip, port) == false)
            return false;
        return true;
    }

    /* ---------------- 数据收发 ---------------- */
    ssize_t Recv(void *buf, size_t len, int flag = 0)
    {
        ssize_t ret = recv(_sockfd, buf, len, flag);
        if (ret > 0)
            return ret;
        if (ret == 0)
            return -1;                    // 对端关闭
        if (errno == EAGAIN || errno == EINTR)
            return 0;
        LOG(LogLevel::ERROR) << "socket recv error";
        return -1;
    }

    ssize_t Send(const void *buf, size_t len, int flag = 0)
    {
        ssize_t ret = send(_sockfd, buf, len, flag);
        if (ret >= 0)
            return ret;
        if (errno == EAGAIN || errno == EINTR)
            return 0;
        LOG(LogLevel::ERROR) << "socket send error";
        return -1;
    }

    ssize_t NonBlockRecv(void *buf, size_t len)
    {
        return Recv(buf, len, MSG_DONTWAIT);
    }

    ssize_t NonBlockSend(void *buf, size_t len)
    {
        return Send(buf, len, MSG_DONTWAIT);
    }

    /* ---------------- 属性设置 ---------------- */
    void ReuseAddress()
    {
        int val = 1;
        setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, (void *)&val, sizeof(int));
        setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, (void *)&val, sizeof(int));
    }

    void NonBlock()
    {
        int flag = fcntl(_sockfd, F_GETFL, 0);
        fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);   // 注意是 F_SETFL,不是 F_SETFD
    }

    /* ---------------- 其他 ---------------- */
    int Fd() { return _sockfd; }

private:
    int _sockfd;
};

2.3 三个容易写错的地方

  这个模块代码不多,但有几个坑几乎人人踩一遍,这里单独列出来。

(1) ReuseAddress() 必须在 bind() 之前

  SO_REUSEADDR 影响的是 bind() 时的判定 :当端口上还挂着 TIME_WAIT 的旧连接时,内核允不允许新 socket 绑定这个端口。

cpp 复制代码
// ❌ 错误顺序:设置在 bind 之后
if (Bind(ip, port) == false) return false;
if (Listen() == false) return false;
ReuseAddress();       // 对这次 bind 已经没有任何作用了

// ✅ 正确顺序
ReuseAddress();
NonBlock();
if (Bind(ip, port) == false) return false;
if (Listen() == false) return false;

  坑在于:写在 bind() 之后,setsockopt 不会报错 (返回 0),看起来"设置成功了",但这次绑定结果早已确定。等到你 kill 掉服务器、3 秒内再重启,就会撞上 bind error: Address already in use------因为上一轮由服务器主动关闭的连接还在 TIME_WAIT(默认 60 秒)。

(2) 设置非阻塞用的是 F_SETFL,不是 F_SETFD

cpp 复制代码
// ❌ 错误:O_NONBLOCK 是"文件状态标志",不是"描述符标志"
int flag = fcntl(_sockfd, F_GETFL, 0);
fcntl(_sockfd, F_SETFD, flag | O_NONBLOCK);

// ✅ 正确
int flag = fcntl(_sockfd, F_GETFL, 0);
fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);

  fcntl 有两类命令:

命令 操作对象 合法标志
F_GETFL / F_SETFL 文件状态标志 O_NONBLOCK、O_APPEND 等
F_GETFD / F_SETFD 文件描述符标志 只有 FD_CLOEXEC

  把 O_NONBLOCK(0x800)传给 F_SETFD,等于往"只认 FD_CLOEXEC"的字段里写了一个无关的位------不报错,也不生效,socket 依然是阻塞的 。这个 bug 极其隐蔽:程序照常跑,直到某天你在 while 里循环 send,才发现自己被卡住了。

  验证方法很简单:

cpp 复制代码
std::cout << (fcntl(fd, F_GETFL, 0) & O_NONBLOCK) << std::endl;   // 非阻塞应打印 0x800

(3) 监听地址默认别用 127.0.0.1

  CreateServer(port, ip = "0.0.0.0") 里的 0.0.0.0 表示监听本机所有网卡 ;如果写成 127.0.0.1,就只有本机回环能连上,同一局域网里的手机、另一台电脑都连不进来。本地调试时体现不出来,等你想用手机测一下,就会发现怎么都连不上。


三、测试

  模块写完先单独验一下:不碰 epoll,就用最朴素的阻塞方式做一个 echo。

cpp 复制代码
#include "../source/server.hpp"

int main()
{
    Socket tcp;
    if (!tcp.CreateServer(8080))
        return 1;

    while (1)
    {
        int fd = tcp.Accept();
        if (fd < 0)
            continue;

        char buf[1024] = {0};                 // 一定要初始化
        ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0);
        if (n > 0)
        {
            send(fd, buf, n, 0);
            LOG(LogLevel::DEBUG) << "echo: " << std::string(buf, n);
        }

        close(fd);                            // 每次处理完就关掉
    }
}

  编译并运行:

bash 复制代码
g++ -std=c++17 -pthread main.cpp -o server
./server

  另开一个终端用 nc 验证:

bash 复制代码
nc 127.0.0.1 8080
hello
# 预期回显 hello

  再确认一下端口状态:

bash 复制代码
ss -tanp | grep 8080

  应该能看到一个 LISTEN 状态的监听套接字。如果你在 3 秒内反复重启服务端,且没有加 ReuseAddress()(或者加错了位置),这里就会报 Address already in use------正好可以用它来验证 2.3 的第 (1) 条。


四、总结

  本节把套接字的生命周期收进了一个类,做了三件事:

  1. 简化调用 :CreateServer(8080) 一行顶掉原来的四步系统调用;
  2. 统一错误处理 :所有 -1 判断都收敛在模块内部,上层只需要看 bool 返回值;
  3. 资源自动化 :析构自动 Close(),Close() 之后把 fd 置 -1,杜绝二次关闭。

  同时我们在实现过程中明确了三件后面会反复用到的事:

  • accept() 返回的才是通信 fd,监听 fd 只负责等连接;
  • send() 可能只发出去一部分,必须配写缓冲区;
  • 非阻塞是 F_SETFL,地址复用要放在 bind 之前。

  不过到目前为止,我们的服务器还是"一个连接一个线程"的思路------Accept() 之后必须马上处理,处理不了就阻塞。下一节我们引入 Channel 与 Poller:把每个 fd 封装成可注册的事件对象,交给 epoll 统一监控,让单线程也能同时盯住成百上千个连接。

  我是曦夜日长,我们下节见。

相关推荐
鹿鸣天涯1 小时前
2026年下半年网络规划设计师论文
网络·计算机网络·网络规划设计师
Star(up up up)1 小时前
Docker系列---Docker容器基本命令(新手小白保姆级实操教程)
运维·学习·程序人生·青少年编程·docker·容器·dubbo
echome8882 小时前
Linux 定时任务:crontab 写法与常见失效原因
linux·运维·网络·定时任务·crontab
老约家的可汗2 小时前
Linux开发三件套:make、git、gdb 入门指南
linux·运维·服务器
从琳开始9892 小时前
从Socket到TCP回显服务器:Java网络编程完整指南
java·服务器·网络·tcp/ip·java-ee
云-缩影3 小时前
做微信小程序,自建服务器还是用云托管:技术选型对比
服务器·微信小程序·小程序
分布式存储与RustFS3 小时前
可观测性数据落进对象存储:Proscia 案例拆解与 ClickHouse 自托管配置
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
安易算力3 小时前
智算中心网络架构:RDMA/IB/以太网选型深度解析与优化实践
网络·架构·php
土星云SaturnCloud3 小时前
基于边缘计算与AI视觉的智慧街面治理方案:架构设计与场景落地
服务器·人工智能·ai·边缘计算