前言

今天继续推进仿 muduo 的 C++ 高并发服务器项目。

昨天主要实现的是 Buffer 缓冲区模块,用来解决 TCP 字节流读写过程中可能出现的半包、粘包、一次发送不完整等问题。Buffer 更偏向应用层数据管理,而今天实现的 Socket 类则更靠近网络 IO 的底层。

在 Linux 网络编程中,如果每个模块都直接调用 socketbindlistenacceptrecvsend 这些系统接口,代码会比较分散,而且错误处理也容易重复。后面继续写 AcceptorTcpServerTcpConnection 的时候,如果不先封装好 socket 操作,代码会越来越乱。

所以今天的目标很明确:封装一个 Socket 类,把裸文件描述符和常用 socket 系统调用统一管理起来,为后续网络库模块打基础。

一、为什么要封装 Socket 类

Linux 下的 socket 本质上也是一个文件描述符,也就是一个 int 类型的 fd。服务端创建连接时,一般要经历下面几个步骤:

socket -> bind -> listen -> accept

客户端连接服务器时,一般是:

socket -> connect

连接建立以后,通信过程主要依赖:

recv / send

如果直接在业务代码里反复写这些系统调用,会有几个问题:

  • 每次都要手动判断返回值,错误处理重复
  • fd 生命周期容易混乱,忘记关闭会造成资源泄漏
  • 阻塞、非阻塞、地址复用等选项容易分散在各处
  • 后续 TcpConnection 之类的模块会被底层细节污染

因此我先封装了一个轻量级的 Socket 类,让它专门负责 socket fd 的创建、绑定、监听、连接、收发和关闭。

二、Socket 类的整体设计

这次的 Socket 类内部只保存一个成员:

private:
    int _sockfd;

构造函数中将 _sockfd 初始化为 -1,表示当前还没有管理有效的 socket:

Socket()
    : _sockfd(-1)
{}

Socket(int fd)
    : _sockfd(fd)
{}

析构函数里调用 Close()

~Socket() {
    Close();
}

这里其实体现了 RAII 的思想:对象的生命周期和资源的生命周期绑定在一起。Socket 对象创建后负责管理 fd,Socket 对象销毁时自动关闭 fd。

这样做的好处是,后面在连接对象析构、异常退出或者局部对象离开作用域时,不需要每次都手动记得 close(fd),可以减少资源泄漏的风险。

三、创建 TCP Socket

创建套接字的接口是 Create()

bool Create() {
    _sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (_sockfd < 0) {
        ERR_LOG("CREATE SOCKET FAILED!!");
        return false;
    }
    return true;
}

这里使用的是:

  • AF_INET:表示 IPv4
  • SOCK_STREAM:表示面向字节流的 TCP
  • IPPROTO_TCP:表示使用 TCP 协议

如果创建失败,socket() 会返回小于 0 的值,这时通过日志输出错误信息,并返回 false

这一步完成以后,_sockfd 就保存了一个有效的 socket 文件描述符。

四、服务端流程:Bind、Listen、Accept

服务端 socket 的核心流程是:绑定地址、开始监听、接收新连接。

1. 绑定地址 Bind

Bind() 负责把 socket 和指定的 IP、端口绑定起来:

bool Bind(const std::string &ip, uint16_t port) {
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = inet_addr(ip.c_str());

    socklen_t len = sizeof(struct sockaddr_in);
    int ret = bind(_sockfd, (struct sockaddr*)&addr, len);
    if (ret < 0) {
        ERR_LOG("BIND ADDRESS FAILED!");
        return false;
    }
    return true;
}

这里需要注意两个转换:

  • htons(port):主机字节序转网络字节序
  • inet_addr(ip.c_str()):点分十进制 IP 字符串转网络字节序整数

服务端如果绑定的是 "0.0.0.0",表示监听本机所有网卡地址。

2. 开始监听 Listen

绑定完成后,调用 Listen() 把 socket 转成监听状态:

bool Listen(int backlog = MAX_LISTEN) {
    int ret = listen(_sockfd, backlog);
    if (ret < 0) {
        ERR_LOG("SOCKET LISTEN FAILED!");
        return false;
    }
    return true;
}

这里的 backlog 经常会在面试中被问到。它不是“服务器最大并发连接数”,而是内核中已完成连接队列的长度上限。客户端完成三次握手后,连接会进入这个队列,等待应用层调用 accept() 取走。

3. 获取新连接 Accept

当监听 socket 上有新连接到来时,调用 Accept()

int Accept() {
    int newfd = accept(_sockfd, NULL, NULL);
    if (newfd < 0) {
        ERR_LOG("SOCKET ACCEPT FAILED!");
        return -1;
    }
    return newfd;
}

accept() 返回的是一个新的 fd。

监听 socket 只负责接收新连接,而真正和客户端通信的是 accept() 返回的新连接 fd。后面写 AcceptorTcpConnection 时,这一点会非常重要。

五、客户端流程:Connect

客户端创建 socket 后,需要主动连接服务器:

bool Connect(const std::string &ip, uint16_t port) {
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);
    addr.sin_addr.s_addr = inet_addr(ip.c_str());

    socklen_t len = sizeof(struct sockaddr_in);
    int ret = connect(_sockfd, (struct sockaddr*)&addr, len);
    if (ret < 0) {
        ERR_LOG("CONNECT SERVER FAILED!");
        return false;
    }
    return true;
}

这部分和服务端的 Bind() 很像,都是先填充 sockaddr_in 结构体。

区别在于:

  • 服务端 bind() 是把自己的 socket 绑定到本地 IP 和端口
  • 客户端 connect() 是连接远端服务器的 IP 和端口

六、数据接收 Recv

连接建立以后,就可以通过 Recv() 接收数据:

ssize_t Recv(void *buf, size_t len, int flag = 0) {
    ssize_t ret = recv(_sockfd, buf, len, flag);
    if (ret <= 0) {
        if (errno == EAGAIN || errno == EINTR) {
            return 0;
        }
        ERR_LOG("SOCKET RECV FAILED!!");
        return -1;
    }
    return ret;
}

这里对 recv() 的返回值做了统一处理:

  • ret > 0:实际读到的字节数
  • ret < 0:读取出错
  • ret == 0:对端关闭连接

代码中把 EAGAINEINTR 单独处理为返回 0

  • EAGAIN:非阻塞情况下,当前接收缓冲区没有数据
  • EINTR:阻塞等待过程中被信号中断

不过这里也有一个后续可以优化的点:recv() 返回 0 本身表示对端正常关闭连接,而当前封装中 EAGAINEINTR 也返回 0,这会让“暂时没读到数据”和“连接关闭”不太容易区分。后面接入 TcpConnection 时,可以进一步细化返回状态。

七、数据发送 Send

发送数据使用 Send()

ssize_t Send(const void *buf, size_t len, int flag = 0) {
    ssize_t ret = send(_sockfd, buf, len, flag);
    if (ret < 0) {
        if (errno == EAGAIN || errno == EINTR) {
            return 0;
        }
        ERR_LOG("SOCKET SEND FAILED!!");
        return -1;
    }
    return ret;
}

这里有一个很重要的点:send() 一次不一定能把所有数据都发送完。

尤其是在非阻塞 IO 场景下,如果内核发送缓冲区空间不足,send() 可能只发送一部分数据,甚至返回 EAGAIN。所以后面必须配合 Buffer 保存剩余数据,等 socket 下次可写时再继续发送。

这也是昨天实现 Buffer 的意义之一。

八、单次非阻塞收发

当前类里还封装了两个非阻塞收发接口:

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

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

MSG_DONTWAIT 表示本次 recv()send() 调用使用非阻塞方式。

它和 fcntl(fd, F_SETFL, O_NONBLOCK) 的区别是:

  • MSG_DONTWAIT 只影响当前这一次系统调用
  • O_NONBLOCK 会改变 fd 本身的阻塞属性

所以 MSG_DONTWAIT 更像是“临时非阻塞”,而 O_NONBLOCK 是“这个 fd 以后都按非阻塞方式工作”。

九、设置 fd 为非阻塞

NonBlock() 通过 fcntl() 修改 fd 属性:

void NonBlock() {
    int flag = fcntl(_sockfd, F_GETFL, 0);
    fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);
}

高并发服务器通常都会使用非阻塞 IO。

原因是事件循环线程不能被某一个连接卡住。比如一个客户端迟迟不发数据,如果服务器在这个 fd 上阻塞等待,那么整个线程都无法继续处理其他连接。非阻塞 IO 配合 epoll 这类 IO 多路复用机制,才能让一个线程同时管理大量连接。

后面实现 ChannelPollerEventLoop 时,非阻塞 fd 会是整个 Reactor 模型的基础。

十、地址和端口复用

ReuseAddress() 用来设置地址复用:

void ReuseAddress() {
    int val = 1;
    setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, (void*)&val, sizeof(int));
    val = 1;
    setsockopt(_sockfd, SOL_SOCKET, SO_REUSEPORT, (void*)&val, sizeof(int));
}

这里设置了两个选项:

  • SO_REUSEADDR
  • SO_REUSEPORT

SO_REUSEADDR 常用于解决服务器重启时端口还处于 TIME_WAIT 等状态,导致短时间内无法重新绑定的问题。

SO_REUSEPORT 允许多个 socket 绑定同一个 IP 和端口,常见用途是多进程或多线程模型下让内核做连接分发。

不过这里有一个细节:地址复用最好在 bind() 之前设置。因为它影响的是绑定行为,如果在 Bind() 之后才调用,实际效果可能不符合预期。

当前代码中的 CreateServer() 是这样写的:

bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0", bool block_flag = false) {
    if (Create() == false) return false;
    if (block_flag) NonBlock();
    if (Bind(ip, port) == false) return false;
    if (Listen() == false) return false;
    ReuseAddress();
    return true;
}

后续可以调整为:

Create -> ReuseAddress -> NonBlock -> Bind -> Listen

另外,block_flag 这个名字也可以再优化一下。因为当前传入 true 时调用的是 NonBlock(),所以它表达的其实是“是否设置为非阻塞”,命名成 nonblocknonblock_flag 会更清晰。

十一、封装服务端和客户端创建流程

为了减少外部使用成本,类中还提供了两个组合接口。

服务端创建:

bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0", bool block_flag = false) {
    if (Create() == false) return false;
    if (block_flag) NonBlock();
    if (Bind(ip, port) == false) return false;
    if (Listen() == false) return false;
    ReuseAddress();
    return true;
}

客户端创建:

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

这样外部使用时就不需要每次都手动组合系统调用。

比如后面 Acceptor 中只需要创建监听 socket,TcpClient 中只需要创建客户端 socket,不必关心每个系统调用的细节。

十二、Socket 和 Buffer 的关系

到这里,项目中已经有了两个很关键的基础模块:

  • Socket:负责底层网络 IO
  • Buffer:负责应用层数据缓存

它们各自解决的问题不一样。

Socket 关心的是 fd 能不能读、能不能写、连接有没有建立、系统调用有没有失败。

Buffer 关心的是数据有没有读完整、业务处理到哪里了、剩余数据要不要暂存、发送没发完的数据要不要下次继续发。

后面写 TcpConnection 时,每个连接大概率都会持有:

一个 socket fd
一个输入缓冲区 input buffer
一个输出缓冲区 output buffer

读事件到来时,从 Socket 读数据到输入 Buffer

写事件到来时,从输出 Buffer 取数据,通过 Socket 继续发送。

这样网络 IO 和数据管理就被拆开了,代码结构会更清楚。

十三、简单测试思路

当前可以先用一个简单的回显服务器验证 Socket 类是否正常。

服务端流程:

1. CreateServer(port)
2. 循环 Accept()
3. 对新连接调用 Recv()
4. 收到数据后调用 Send() 原样发回去

客户端流程:

1. CreateClient(port, ip)
2. Send() 发送字符串
3. Recv() 接收服务端回显

重点验证:

  • socket 能否成功创建
  • 服务端能否成功绑定和监听端口
  • 客户端能否成功连接
  • 服务端能否成功 accept() 新连接
  • 基础 recv() / send() 是否正常工作

这一步不追求复杂并发,只要先验证封装的基础接口可用即可。

十四、面试可能提问的点

1. 为什么要封装 Socket 类?

主要是为了降低系统调用的使用成本,统一错误处理,并通过 RAII 管理 fd 生命周期。

如果不封装,后面的 AcceptorTcpConnectionTcpServer 中会到处散落 socketbindlistenacceptrecvsend 等系统调用,代码可读性和可维护性都会变差。

2. 什么是 RAII?这里是怎么体现的?

RAII 是 Resource Acquisition Is Initialization,也就是资源获取即初始化。

它的核心思想是:用对象生命周期管理资源生命周期。

在这个 Socket 类中,_sockfd 是被管理的资源,析构函数里调用 Close() 关闭 fd。这样 Socket 对象销毁时,fd 也会被自动释放。

3. bind()listen()accept() 分别做什么?

bind() 用来把 socket 绑定到本地 IP 和端口。

listen() 用来把 socket 转换成监听 socket,让它可以接收连接请求。

accept() 用来从内核已完成连接队列中取出一个连接,并返回一个新的通信 fd。

4. listen(backlog) 中的 backlog 是什么?

backlog 表示内核已完成连接队列的长度上限。

它不是服务器最大并发连接数。服务器能同时维护多少连接,还和系统 fd 数量、内存、事件循环处理能力等因素有关。

5. 为什么需要 SO_REUSEADDR

服务器关闭后,连接可能会进入 TIME_WAIT 状态,端口短时间内不能立即复用。

开启 SO_REUSEADDR 后,可以减少服务器重启时因为端口占用导致 bind() 失败的问题。

6. SO_REUSEADDRSO_REUSEPORT 有什么区别?

SO_REUSEADDR 更偏向地址复用,常用于解决服务端重启后端口无法立即绑定的问题。

SO_REUSEPORT 允许多个 socket 绑定同一个 IP 和端口,常用于多进程服务器,让多个进程共同监听同一个端口,由内核进行连接分发。

7. 为什么高并发服务器需要非阻塞 IO?

因为事件循环线程不能被单个连接阻塞。

如果某个客户端一直不发数据,而服务器在这个连接上阻塞等待,那么这个线程就无法继续处理其他连接。非阻塞 IO 配合 epoll,可以让一个线程同时管理大量 fd。

8. EAGAINEINTR 分别表示什么?

EAGAIN 表示当前操作暂时无法完成。

在非阻塞读场景中,它通常表示当前没有数据可读;在非阻塞写场景中,它通常表示当前发送缓冲区暂时没有空间。

EINTR 表示系统调用被信号中断,可以根据具体场景选择重试。

9. recv() 返回 0 代表什么?

recv() 返回 0 通常表示对端已经正常关闭连接。

这一点要和 EAGAIN 区分开。EAGAIN 是当前暂时没数据,连接并没有关闭;而 recv() 返回 0 是连接已经被对端关闭。

10. send() 一次一定能发送完所有数据吗?

不一定。

send() 返回的是本次实际发送的字节数。尤其在非阻塞模式下,如果发送缓冲区空间不足,可能只发送一部分数据。

所以高并发网络库一般会有输出缓冲区。没发完的数据先保存起来,等 fd 再次可写时继续发送。

11. MSG_DONTWAITO_NONBLOCK 有什么区别?

MSG_DONTWAIT 只影响当前这一次 recv()send() 调用。

O_NONBLOCK 是通过 fcntl() 修改 fd 属性,会让这个 fd 后续所有 IO 操作都变成非阻塞。

简单来说,MSG_DONTWAIT 是单次非阻塞,O_NONBLOCK 是永久非阻塞。

12. CreateServer() 中为什么地址复用最好放在 Bind() 前?

因为 SO_REUSEADDRSO_REUSEPORT 影响的是 socket 的绑定行为。

如果已经 bind() 完了再设置,可能无法影响本次绑定过程,所以更合理的顺序是:

Create -> ReuseAddress -> Bind -> Listen

如果还要设置非阻塞,也可以在 Bind() 前后设置,但通常服务端监听 fd 在进入事件循环前设置好即可。

13. 当前这个 Socket 类还有哪些可以继续优化的地方?

当前版本已经能满足基础封装需求,但后续可以继续优化:

  • 禁用拷贝构造和拷贝赋值,避免多个 Socket 对象管理同一个 fd
  • 增加移动构造和移动赋值,支持 fd 所有权转移
  • ReuseAddress()NonBlock() 中补充系统调用返回值检查
  • Recv() 中区分 recv() 返回 0 和 EAGAIN
  • block_flag 改名为 nonblock_flag
  • ReuseAddress() 调整到 Bind() 之前调用

这些优化不影响当前学习主线,但在真正写成网络库时会更严谨。

总结

今天完成了 Socket 类的基础封装,把 Linux socket 编程中常见的系统调用整理到一个类里,包括创建、绑定、监听、连接、接收、发送、关闭、非阻塞和地址复用。

这个模块本身并不复杂,但它是后续网络库的底座。后面实现 Acceptor 时,需要用监听 socket 接收新连接;实现 TcpConnection 时,需要用连接 socket 进行读写;实现 EventLoop 时,又需要把这些 fd 注册到事件循环中统一管理。

从项目推进角度看,昨天的 Buffer 解决的是“数据怎么存”,今天的 Socket 解决的是“数据怎么进出网络”。两者组合起来,后面就可以继续往 Reactor 模型的核心模块推进了。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐