Socket 编程基础:类型、生命周期与模式
Socket 编程,说白了就是让两台机器能互相说话。我做了这么多年高并发系统,见过太多人栽在 Socket 的基础概念上。今天咱们就把这块地基打牢。
Socket 类型:流式 vs 数据报
Socket 分两种,就像寄信和打电话的区别。我个人习惯用一张表来对比,清晰明了:
| 特性 | 流式 Socket (SOCK_STREAM) | 数据报 Socket (SOCK_DGRAM) |
|---|---|---|
| 底层协议 | TCP | UDP |
| 连接方式 | 面向连接,必须先建立连接 | 无连接,直接发数据 |
| 数据边界 | 无边界,像水流一样连续 | 有边界,每个报文独立 |
| 可靠性 | 可靠,保证顺序和完整 | 不可靠,可能丢包或乱序 |
| 典型场景 | HTTP、FTP、数据库连接 | DNS、视频流、游戏状态同步 |
你想想看,流式 Socket 就像打电话——你先拨号,对方接听,然后你们才能说话。数据报 Socket 就像寄明信片——你写好地址扔邮筒里,对方能不能收到、什么时候收到,你没法保证。
Socket 生命周期:从生到死
一个 Socket 从创建到关闭,要经历五个阶段。我在项目中遇到过不少问题,都是因为对生命周期理解不透彻导致的。
1. 创建(socket)
这是第一步,告诉系统你要用哪种协议族和类型:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
嗯,这里要注意:AF_INET 是 IPv4,SOCK_STREAM 是 TCP。第三个参数 0 表示让系统自动选择协议。我见过有人传了个乱七八糟的数字进去,结果 socket 创建失败还不知道为什么。
2. 绑定(bind)
把 Socket 和本地地址、端口绑在一起。服务端必须做这一步:
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind failed");
close(sockfd);
exit(EXIT_FAILURE);
}
setsockopt 设置 SO_REUSEADDR。
3. 监听(listen)
告诉内核:我准备好了,可以接受连接请求了。这里有个关键参数——backlog:
if (listen(sockfd, 10) < 0) {
perror("listen failed");
exit(EXIT_FAILURE);
}
backlog 是连接请求队列的最大长度。你想想看,如果同时有 100 个客户端请求连接,而 backlog 只设了 5,那后面 95 个就会被内核直接拒绝。高并发场景下,这个值要设大一些,但也不是越大越好——每个等待的连接都要占用内核资源。
4. 连接(connect / accept)
这是两端的事。客户端调用 connect,服务端调用 accept:
// 客户端
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
exit(EXIT_FAILURE);
}
// 服务端
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept failed");
continue; // 别 exit,继续接受下一个
}
这里有个细节:accept 返回的是一个新的 Socket 描述符。原来的监听 Socket 还在继续监听,新的 Socket 才用来和客户端通信。我刚开始做的时候以为 accept 之后原来的 Socket 就不能用了,其实不是。
5. 关闭(close)
通信结束,释放资源:
close(client_fd); // 关闭连接 Socket
close(server_fd); // 关闭监听 Socket
shutdown 比 close 更可控——它可以只关闭写方向,保留读方向继续接收剩余数据。
阻塞与非阻塞模式
这是 Socket 编程里最容易让人困惑的地方。说白了,阻塞就是「等」,非阻塞就是「不等」。
阻塞模式
默认情况下,Socket 是阻塞的。你调用 recv,如果没有数据,线程就卡在那里等:
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
// 如果没有数据,这行代码永远不会执行
阻塞模式的好处是编程简单,逻辑清晰。坏处是——一个线程只能处理一个 Socket。如果这个 Socket 一直没数据,线程就白白浪费了。
非阻塞模式
设置非阻塞后,recv 会立即返回。有数据就返回数据长度,没数据就返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK:
// 设置非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
// 非阻塞读取
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有数据,去做别的事
printf("no data now, do other work\n");
} else {
perror("recv error");
}
}
你想想看,非阻塞模式让一个线程可以同时处理成百上千个 Socket。但代价是代码变复杂了——你得不断轮询,或者用 select、epoll 来管理。
| 对比项 | 阻塞模式 | 非阻塞模式 |
|---|---|---|
| 编程复杂度 | 低,逻辑直观 | 高,需要状态管理 |
| CPU 利用率 | 低(线程休眠等待) | 高(需要轮询或事件驱动) |
| 并发能力 | 低(一个线程一个连接) | 高(一个线程多个连接) |
| 典型场景 | 简单客户端、低并发服务 | 高并发服务器、网关 |
知识体系总览
下面这张图把本章的核心知识点串起来了。我习惯用这种图来梳理思路,你也试试看:
这张图把 Socket 类型、生命周期五阶段、阻塞与非阻塞模式串在了一起。你仔细看,生命周期是中间的主干,类型决定了你用什么协议,模式决定了你怎么处理 I/O。这三者组合起来,就是 Socket 编程的全部基础。