系列文章目录
文章目录
- 系列文章目录
- 前言
- [一、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)
- [(1) `ReuseAddress()` 必须在 `bind()` 之前](#(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) { /* 错误处理 */ }
问题有三个:
- 样板代码重复:每写一个服务端就要抄一遍地址结构体的填充;
- 错误处理分散 :
socket、bind、listen三处都要判-1,最后往往漏掉; - 资源容易泄漏 :fd 是裸的整数,没人负责在合适的时机
close。
总结起来,Socket 模块主要解决一件事:对服务器套接字使用的简化。把"创建并配置一个能用的套接字"这件事收敛成一个函数,把"关掉它"收敛成另一个函数,剩下的细节全部藏进类里。
1.2 实现功能
本质目的:实现通信套接字封装,简化套接字的使用。具体包含:
- 对套接字初始化的系列函数的封装(
Create/Close,并借析构做 RAII) - TCP 服务端函数的实现(
Bind/Listen/Accept,以及一步到位的CreateServer) - TCP 客户端函数的实现(
Connect/CreateClient) - 数据收发函数的实现(
Recv/Send及非阻塞版本) - 套接字属性的设置(
ReuseAddress/NonBlock) - 描述符的对外获取(
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) 条。
四、总结
本节把套接字的生命周期收进了一个类,做了三件事:
- 简化调用 :
CreateServer(8080)一行顶掉原来的四步系统调用; - 统一错误处理 :所有
-1判断都收敛在模块内部,上层只需要看bool返回值; - 资源自动化 :析构自动
Close(),Close()之后把 fd 置 -1,杜绝二次关闭。
同时我们在实现过程中明确了三件后面会反复用到的事:
accept()返回的才是通信 fd,监听 fd 只负责等连接;send()可能只发出去一部分,必须配写缓冲区;- 非阻塞是
F_SETFL,地址复用要放在bind之前。
不过到目前为止,我们的服务器还是"一个连接一个线程"的思路------Accept() 之后必须马上处理,处理不了就阻塞。下一节我们引入 Channel 与 Poller:把每个 fd 封装成可注册的事件对象,交给 epoll 统一监控,让单线程也能同时盯住成百上千个连接。
我是曦夜日长,我们下节见。