本文是 C++ 系列 的第五篇。
写 C++ 网络代码时,我最不想直接把 socket()、connect()、send()、recv() 散在业务逻辑里。
这些函数本身不难,真正容易出问题的是边界:
- socket 文件描述符谁负责关闭
- 连接失败时资源会不会泄漏
send()有没有一次发完recv()读到的是不是完整消息- 服务端 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 拥有 Socket,Socket 拥有 fd。外部代码不需要关心什么时候 close(),连接对象析构时会自动释放资源。
sendAll() 里没有假设 send() 一次能把所有数据发完。send() 返回的是实际写入内核发送缓冲区的字节数,可能小于你传进去的长度,所以必须循环。
3. 客户端连接:失败路径也不能泄漏 fd
客户端连接服务器的流程是:
- 创建 socket
- 填地址
- 调用
connect - 成功后把 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:
- 监听 socket:负责
bind、listen、accept - 连接 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"); } }}这段代码是单线程阻塞模型。它一次只处理一个客户端,客户端断开后才接受下一个连接。它不适合高并发,但结构很清楚:
TcpServer负责监听和接收连接TcpConnection负责连接生命周期和字节收发LineReader负责按行解析协议
后面如果要支持多个客户端,可以把每个 TcpConnection 交给一个 std::jthread,或者进一步做线程池。但在扩展之前,先把单连接模型写清楚更重要。
8. 错误处理:不要只看返回值小于 0
POSIX socket 接口通常用返回值表示失败,并把错误原因放在 errno 里。
网络代码至少要区分几类情况:
recv()返回0:对端正常关闭连接send()/recv()返回-1且errno == EINTR:被信号打断,可以重试connect()、bind()、listen()失败:通常应该直接抛异常或返回错误- 部分写入:
send()成功,但只写入了一部分字节
这就是为什么我更愿意把底层 API 包在类里。业务代码不应该到处判断 errno,应该调用语义更清楚的接口:sendAll()、readSome()、readLine()。
9. 这篇没有处理的东西
这篇刻意没有展开这些内容:
- 非阻塞 socket
select/poll/epoll- TLS
- IPv6 和域名解析
- 粘包拆包的长度字段协议
- 多线程服务端和线程池
这些都很重要,但不是第一步。第一步先把 socket 生命周期、连接对象、部分读写和协议边界处理清楚。
10. 小结
这篇先记住几个判断:
- socket fd 是资源,应该用 RAII 对象管理
- 连接对象应该拥有 socket,而不是到处传裸 fd
send()不保证一次写完,可靠发送需要循环recv()读到的是字节,不是完整消息- TCP 没有业务消息边界,拆包逻辑要自己写
- 服务端监听 socket 和客户端连接 socket 是两类职责
- 业务层应该调用清晰接口,而不是散落处理
errno
网络编程不是把几个系统调用背下来。更关键的是边界:资源什么时候释放,字节什么时候构成消息,连接关闭时上层怎么感知。把这些边界封进对象里,后面再加线程池、epoll 或更复杂的协议,代码才不会散。