Socket 封装详解
一、整体结构
#define MAX_LISTEN 1024
class Socket {
private:
int _sockfd;
public:
Socket();
Socket(int fd);
~Socket();
int Fd();
bool Create();
bool Bind(const std::string &ip, uint16_t port);
bool Listen(int backlog = MAX_LISTEN);
bool Connect(const std::string &ip, uint16_t port);
int Accept();
ssize_t Recv(void *buf, size_t len, int flag = 0);
ssize_t NonBlockRecv(void *buf, size_t len);
ssize_t Send(const void *buf, size_t len, int flag = 0);
ssize_t NonBlockSend(void *buf, size_t len);
void Close();
bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0", bool block_flag = false);
bool CreateClient(uint16_t port, const std::string &ip);
void ReuseAddress();
void NonBlock();
};
可以看到,这个类把 socket()、bind()、listen()、connect()、accept()、recv()、send()、close()、setsockopt()、fcntl() 这些系统调用全部封装了起来。
二、逐块详细讲解
1. 宏定义和成员变量
#define MAX_LISTEN 1024
MAX_LISTEN 是监听队列的最大长度,也就是 listen() 的第二个参数 backlog。
1024 表示内核为这个监听 socket 维护的已完成连接队列和未完成连接队列之和的最大值。简单说就是:最多允许有多少个客户端同时等待被 accept()。
private:
int _sockfd;
_sockfd 就是保存 socket 文件描述符的成员变量。
Linux 中 socket 也是一个文件,所以它用一个整数 fd 来表示。
2. 构造函数 / 析构函数 / Fd()
Socket() : _sockfd(–1) {}
Socket(int fd) : _sockfd(fd) {}
~Socket() { Close(); }
- 默认构造函数:把 _sockfd 初始化为 -1。
-1 表示当前还没有有效的 socket,这是一个常见的"无效值"约定。 - 带参构造函数:允许直接用一个已经存在的 fd 来构造对象。
这通常用在 accept() 返回新连接后,我们想用 Socket 对象来管理它。 - 析构函数:调用 Close()。
这是 C++ 里非常重要的 RAII 思想:对象销毁时自动释放资源,避免忘记 close() 导致 fd 泄漏。
int Fd() { return _sockfd; }
Fd() 返回当前保存的 fd,方便其他模块使用。
这个函数很简单,但很常用,因为很多系统调用还需要裸 fd。
3. Create():创建套接字
bool Create() {
_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (_sockfd < 0) {
ERR_LOG("CREATE SOCKET FAILED!!");
return false;
}
return true;
}
int socket(int domain, int type, int protocol);
- AF_INET:使用 IPv4 地址族。
- SOCK_STREAM:使用面向连接的 TCP 协议。
- IPPROTO_TCP:明确指定 TCP 协议。其实对于 SOCK_STREAM,第三个参数通常传 0 也可以,但这里写清楚更好。
成功返回一个非负整数 fd,失败返回 -1。
这里如果失败,打印错误日志,返回 false。
4. Bind():绑定地址和端口
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;
}
这里出现了一个重要的结构体:sockaddr_in,它表示一个 IPv4 地址。
- sin_family = AF_INET:地址族,和 socket() 保持一致。
- sin_port = htons(port):
htons 是 Host TO Network Short 的缩写,把主机字节序的 16 位端口号转成网络字节序。
网络传输要求统一使用大端字节序,而不同主机可能是小端,所以必须转换。 - sin_addr.s_addr = inet_addr(ip.c_str()):
inet_addr 把点分十进制字符串(如 "192.168.1.10")转换成 32 位网络字节序的二进制地址。 - bind() 的第二个参数原本是 struct sockaddr*,所以要把 sockaddr_in* 强转过去。
这是 C 语言时代遗留的多态设计,C++ 里只能强转。
注意:如果 ip 传的是 "0.0.0.0",inet_addr 会返回 INADDR_ANY,表示绑定到本机所有网卡地址。
5. Listen():开始监听
bool Listen(int backlog = MAX_LISTEN) {
int ret = listen(_sockfd, backlog);
if (ret < 0) {
ERR_LOG("SOCKET LISTEN FAILED!");
return false;
}
return true;
}
listen() 把一个 socket 从主动套接字变成被动套接字,开始接受连接请求。
- backlog:我们已经说过,是内核维护的连接队列最大长度。
- 默认参数 MAX_LISTEN,所以调用时可以省略。
6. Connect():客户端主动连接服务器
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() 几乎一样,区别是调用 connect()。
客户端不需要绑定自己的端口,内核会自动分配一个随机端口,所以这里直接填服务器地址。
注意:connect() 在非阻塞 socket 上会立即返回 -1,并且 errno 可能是 EINPROGRESS,表示连接正在建立中。
7. Accept():获取新连接
int Accept() {
int newfd = accept(_sockfd, NULL, NULL);
if (newfd < 0) {
ERR_LOG("SOCKET ACCEPT FAILED!");
return –1;
}
return newfd;
}
accept() 从监听队列里取出一个已完成的连接,返回一个新的 fd,这个新 fd 用来和客户端通信。
- 第 2、3 个参数传 NULL 表示不关心客户端的地址信息。
- 如果失败返回 -1。
if (newfd < 0) {
if (errno == EAGAIN || errno == EINTR) return –1; // 不算错误
ERR_LOG("SOCKET ACCEPT FAILED!");
return –1;
}
8. Recv() 和 NonBlockRecv()
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() 从 socket 接收数据。
- buf:接收缓冲区。
- len:缓冲区大小。
- flag:默认 0,表示阻塞接收。
返回值情况:
- ret > 0:实际收到的字节数。
- ret == 0:对端已经关闭连接(正常关闭)。
- ret < 0:出错,需要看 errno。
代码里 if (ret <= 0) 把 ret == 0 和 ret < 0 都进来了。
然后判断:
- errno == EAGAIN:在非阻塞模式下,表示当前没有数据可读,不是错误。
- errno == EINTR:系统调用被信号打断,也不是错误。
9. Send() 和 NonBlockSend()
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() 发送数据。
- ret > 0:实际发送的字节数。
- ret == 0:一般不会出现,除非 len == 0。
- ret < 0:出错。
这里同样处理了 EAGAIN 和 EINTR。
在非阻塞模式下,EAGAIN 表示发送缓冲区已满,暂时无法发送,不是错误。
返回 0 表示"这次没有发送出去",调用者需要稍后重试。
ssize_t NonBlockSend(void *buf, size_t len) {
if (len == 0) return 0;
return Send(buf, len, MSG_DONTWAIT);
}
先判断 len == 0,避免无意义调用。
然后使用 MSG_DONTWAIT 非阻塞发送。
注意:Send() 返回 0 表示没有发送数据,但如果你真的发送了 0 字节,send() 也会返回 0。
10. Close():关闭 socket
void Close() {
if (_sockfd != –1) {
close(_sockfd);
_sockfd = –1;
}
}
这个函数很关键:
- 先判断 _sockfd != -1,防止重复关闭。
- 调用系统 close()。
- 把 _sockfd 重新设为 -1,表示已经关闭。
这是 RAII 的核心:析构函数调用 Close(),保证对象销毁时 fd 一定被关闭。
11. CreateServer():创建服务端连接
bool CreateServer(uint16_t port, const std::string &ip = "0.0.0.0", bool block_flag = false) {
//1. 创建套接字,2. 绑定地址,3. 开始监听,4. 设置非阻塞, 5. 启动地址重用
if (Create() == false) return false;
ReuseAddress();
if (block_flag) NonBlock();
if (Bind(ip, port) == false) return false;
if (Listen() == false) return false;
return true;
}
这个函数把服务端的初始化流程封装了起来。
12. CreateClient():创建客户端连接
bool CreateClient(uint16_t port, const std::string &ip) {
if (Create() == false) return false;
if (Connect(ip, port) == false) return false;
return true;
}
客户端流程简单:创建 socket,然后连接服务器。
13. 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));
}
setsockopt() 用来设置 socket 选项。
- SO_REUSEADDR:允许重用 TIME_WAIT 状态下的本地地址。
服务器重启时,如果之前的连接还处于 TIME_WAIT,没有这个选项会导致 bind() 失败。 - SO_REUSEPORT:允许多个 socket 绑定到完全相同的地址和端口。
这在多进程/多线程服务器中很常用,可以让多个进程同时监听同一个端口,内核负责负载均衡。
注意:SO_REUSEPORT 是 Linux 较新内核才支持的,且必须在 bind() 之前设置。
14. NonBlock():设置非阻塞
void NonBlock() {
int flag = fcntl(_sockfd, F_GETFL, 0);
fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);
}
fcntl() 是一个非常强大的文件控制函数。
- F_GETFL:获取当前文件状态标志。
- F_SETFL:设置文件状态标志。
- O_NONBLOCK:非阻塞标志。
这里先获取原来的 flag,然后加上 O_NONBLOCK,再设置回去。
这样不会覆盖其他已经存在的标志。
为什么需要非阻塞?
在高并发服务器中,如果 socket 是阻塞的,那么一次 recv() 或 send() 可能会阻塞整个线程,导致其他连接无法处理。
非阻塞 socket 配合 IO 多路复用(如 epoll)才能高效处理大量连接。



