2249 字
11 分钟
C++ 网络编程:先把 socket 当成资源对象管理

本文是 C++ 系列 的第五篇。

上一篇:C++ 线程库:用 RAII 管线程,用对象管共享状态

写 C++ 网络代码时,我最不想直接把 socket()connect()send()recv() 散在业务逻辑里。

这些函数本身不难,真正容易出问题的是边界:

  1. socket 文件描述符谁负责关闭
  2. 连接失败时资源会不会泄漏
  3. send() 有没有一次发完
  4. recv() 读到的是不是完整消息
  5. 服务端 accept 出来的连接由谁管理

这篇只写 Linux/POSIX socket 这一条线。跨平台库、异步 IO、epoll、协程先不展开。先把最基础的阻塞式 TCP 代码写得像 C++:资源由对象持有,对象析构自动释放,读写边界在接口里说清楚。

1. socket 本质上是一个需要关闭的资源#

在 Linux 下,socket() 返回的是文件描述符。它和普通文件 fd 一样,用完需要 close()

如果手动管理,很容易出现这种代码:

int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
throw std::runtime_error("socket failed");
}
// 中间任何地方 throw,都可能忘记 close(fd)
::close(fd);

这段代码的问题不是功能不对,而是资源释放靠人记。C++ 里更自然的方式是写一个只负责管理 fd 的小类。

#include <unistd.h>
#include <utility>
class Socket {
public:
explicit Socket(int fd = -1) : fd_(fd) {}
~Socket() {
if (fd_ >= 0) {
::close(fd_);
}
}
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
Socket(Socket&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
Socket& operator=(Socket&& other) noexcept {
if (this != &other) {
reset();
fd_ = std::exchange(other.fd_, -1);
}
return *this;
}
int get() const {
return fd_;
}
void reset(int fd = -1) {
if (fd_ >= 0) {
::close(fd_);
}
fd_ = fd;
}
private:
int fd_{-1};
};

这段代码只做一件事:持有一个 fd,并在析构时关闭。它禁止拷贝,允许移动,避免两个对象同时关闭同一个 fd。

这就是 RAII 在网络编程里的第一层价值:socket 的生命周期跟着对象走

2. 连接对象应该拥有 socket#

裸 fd 不应该在代码里到处传。建立连接之后,可以用一个 TcpConnection 表示“一条 TCP 连接”。

#include <cerrno>
#include <cstring>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
class TcpConnection {
public:
explicit TcpConnection(Socket socket) : socket_(std::move(socket)) {}
void sendAll(std::string_view data) {
const char* buffer = data.data();
std::size_t left = data.size();
while (left > 0) {
ssize_t n = ::send(socket_.get(), buffer, left, 0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
throw std::runtime_error(std::strerror(errno));
}
if (n == 0) {
throw std::runtime_error("send returned 0");
}
buffer += n;
left -= static_cast<std::size_t>(n);
}
}
private:
Socket socket_;
};

这段代码里,TcpConnection 拥有 SocketSocket 拥有 fd。外部代码不需要关心什么时候 close(),连接对象析构时会自动释放资源。

sendAll() 里没有假设 send() 一次能把所有数据发完。send() 返回的是实际写入内核发送缓冲区的字节数,可能小于你传进去的长度,所以必须循环。

3. 客户端连接:失败路径也不能泄漏 fd#

客户端连接服务器的流程是:

  1. 创建 socket
  2. 填地址
  3. 调用 connect
  4. 成功后把 fd 交给连接对象
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
TcpConnection connectTo(std::string_view ip, uint16_t port) {
Socket socket(::socket(AF_INET, SOCK_STREAM, 0));
if (socket.get() < 0) {
throw std::runtime_error("socket failed");
}
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
std::string ipString(ip);
if (::inet_pton(AF_INET, ipString.c_str(), &addr.sin_addr) != 1) {
throw std::runtime_error("invalid ip address");
}
if (::connect(socket.get(), reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
throw std::runtime_error(std::strerror(errno));
}
return TcpConnection(std::move(socket));
}

这段代码里,socket 是局部对象。如果 inet_pton()connect() 中途失败并抛异常,Socket 析构函数仍然会关闭 fd。成功时,fd 被移动进 TcpConnection,由连接对象继续管理。

这里把 std::string_view 转成了 std::string,是因为 inet_pton() 需要 C 风格字符串。string_view 只表示一段字符范围,不保证末尾有 \0

这就是把 C 接口包成 C++ 对象之后最直接的收益:失败路径不再需要到处手写清理代码。

4. recv() 读到的是字节,不是完整消息#

TCP 是字节流。对方发了几次,不等于你就能收几次;你一次 recv() 读到的数据也不一定是一条完整消息。

可以先提供一个“读一段”的接口:

class TcpConnection {
public:
explicit TcpConnection(Socket socket) : socket_(std::move(socket)) {}
std::string readSome(std::size_t maxSize = 4096) {
std::string buffer(maxSize, '\0');
while (true) {
ssize_t n = ::recv(socket_.get(), buffer.data(), buffer.size(), 0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
throw std::runtime_error(std::strerror(errno));
}
if (n == 0) {
return {};
}
buffer.resize(static_cast<std::size_t>(n));
return buffer;
}
}
private:
Socket socket_;
};

这段代码只表达“从连接里读一段字节”。如果 recv() 返回 0,表示对端关闭连接。它不承诺读到一条完整业务消息。

这点很重要。网络编程里很多 bug 都来自把 TCP 当成“消息队列”。TCP 只保证字节顺序,不帮你划分消息边界。

5. 协议边界要自己定义#

如果业务消息是按行发送,可以写一个简单的行缓冲器。它维护内部缓存,每次从 socket 读一些数据,直到遇到 \n

#include <optional>
class LineReader {
public:
explicit LineReader(TcpConnection& connection) : connection_(connection) {}
std::optional<std::string> readLine() {
while (true) {
auto pos = buffer_.find('\n');
if (pos != std::string::npos) {
std::string line = buffer_.substr(0, pos);
buffer_.erase(0, pos + 1);
return line;
}
std::string chunk = connection_.readSome();
if (chunk.empty()) {
if (buffer_.empty()) {
return std::nullopt;
}
std::string lastLine = std::move(buffer_);
buffer_.clear();
return lastLine;
}
buffer_ += chunk;
}
}
private:
TcpConnection& connection_;
std::string buffer_;
};

这段代码把“按行拆包”从连接对象里拆出来。TcpConnection 只负责收发字节,LineReader 负责按业务协议解释字节。

这种分层很有用:socket 层不要知道业务消息格式,协议层不要直接管理 fd 生命周期。

6. 服务端:监听 socket 和连接 socket 是两类对象#

服务端至少有两种 socket:

  1. 监听 socket:负责 bindlistenaccept
  2. 连接 socket:负责和某个客户端收发数据

这两个职责不应该混在一个类里。

#include <netinet/in.h>
#include <sys/socket.h>
class TcpServer {
public:
explicit TcpServer(uint16_t port)
: listenSocket_(::socket(AF_INET, SOCK_STREAM, 0)) {
if (listenSocket_.get() < 0) {
throw std::runtime_error("socket failed");
}
int reuse = 1;
if (::setsockopt(listenSocket_.get(), SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
throw std::runtime_error(std::strerror(errno));
}
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (::bind(listenSocket_.get(), reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
throw std::runtime_error(std::strerror(errno));
}
if (::listen(listenSocket_.get(), SOMAXCONN) < 0) {
throw std::runtime_error(std::strerror(errno));
}
}
TcpConnection acceptOne() {
while (true) {
int clientFd = ::accept(listenSocket_.get(), nullptr, nullptr);
if (clientFd >= 0) {
return TcpConnection(Socket(clientFd));
}
if (errno == EINTR) {
continue;
}
throw std::runtime_error(std::strerror(errno));
}
}
private:
Socket listenSocket_;
};

这段代码里,TcpServer 拥有监听 socket;每次 acceptOne() 成功,就返回一个新的 TcpConnection,由连接对象管理客户端 socket。

SO_REUSEADDR 是为了让服务重启时更容易重新绑定同一个端口。这里暂时不展开 TCP 状态机,只先记住服务端绑定端口时经常需要它。

7. 一个最小 echo server 的结构#

把前面的对象拼起来,可以写一个阻塞式 echo server:

int main() {
TcpServer server(8080);
while (true) {
TcpConnection connection = server.acceptOne();
LineReader reader(connection);
while (auto line = reader.readLine()) {
connection.sendAll(*line + "\n");
}
}
}

这段代码是单线程阻塞模型。它一次只处理一个客户端,客户端断开后才接受下一个连接。它不适合高并发,但结构很清楚:

  1. TcpServer 负责监听和接收连接
  2. TcpConnection 负责连接生命周期和字节收发
  3. LineReader 负责按行解析协议

后面如果要支持多个客户端,可以把每个 TcpConnection 交给一个 std::jthread,或者进一步做线程池。但在扩展之前,先把单连接模型写清楚更重要。

8. 错误处理:不要只看返回值小于 0#

POSIX socket 接口通常用返回值表示失败,并把错误原因放在 errno 里。

网络代码至少要区分几类情况:

  1. recv() 返回 0:对端正常关闭连接
  2. send() / recv() 返回 -1errno == EINTR:被信号打断,可以重试
  3. connect()bind()listen() 失败:通常应该直接抛异常或返回错误
  4. 部分写入:send() 成功,但只写入了一部分字节

这就是为什么我更愿意把底层 API 包在类里。业务代码不应该到处判断 errno,应该调用语义更清楚的接口:sendAll()readSome()readLine()

9. 这篇没有处理的东西#

这篇刻意没有展开这些内容:

  1. 非阻塞 socket
  2. select / poll / epoll
  3. TLS
  4. IPv6 和域名解析
  5. 粘包拆包的长度字段协议
  6. 多线程服务端和线程池

这些都很重要,但不是第一步。第一步先把 socket 生命周期、连接对象、部分读写和协议边界处理清楚。

10. 小结#

这篇先记住几个判断:

  1. socket fd 是资源,应该用 RAII 对象管理
  2. 连接对象应该拥有 socket,而不是到处传裸 fd
  3. send() 不保证一次写完,可靠发送需要循环
  4. recv() 读到的是字节,不是完整消息
  5. TCP 没有业务消息边界,拆包逻辑要自己写
  6. 服务端监听 socket 和客户端连接 socket 是两类职责
  7. 业务层应该调用清晰接口,而不是散落处理 errno

网络编程不是把几个系统调用背下来。更关键的是边界:资源什么时候释放,字节什么时候构成消息,连接关闭时上层怎么感知。把这些边界封进对象里,后面再加线程池、epoll 或更复杂的协议,代码才不会散。