从零掌握端口复用机制 - 第3节
Socket 是什么?先别急着背定义
很多人一上来就背「Socket 是网络通信的端点」,背完还是不会用。我换个说法——Socket 就是操作系统给你的一根网线插口。你写网络程序,本质上就是在这根插口上收发数据。
我记得刚入行那会儿,带我的老工程师说了句话,我一直记着:「Socket 就是文件描述符的变种」。你想想看,Linux 里一切皆文件。你打开一个普通文件,得到一个 fd;你创建一个 Socket,得到的也是一个 fd。只不过这个 fd 背后不是硬盘上的数据块,而是网络协议栈里的收发缓冲区。
一句话总结:Socket = 文件描述符 + 网络协议栈。你读写 Socket 就像读写文件,只不过数据跑到了网络上。
Socket 类型:流式 vs 数据报
Socket 分两种,说白了就是打电话和寄快递的区别。
流式 Socket(SOCK_STREAM)
对应 TCP。特点就三个字:稳、序、重。
- 稳:数据保证到达,丢包会自动重传
- 序:先发的数据先到,不会乱序
- 重:没有消息边界,你发 100 字节,对端可能分两次收 50+50,也可能一次收 100
我在项目中遇到过最典型的坑就是「粘包」。客户端连续发了两个包,服务端一次 recv 全收走了。这不是 TCP 的错,TCP 本来就是流,没有边界。解决方案?自己加消息头或者用固定分隔符。
数据报 Socket(SOCK_DGRAM)
对应 UDP。特点也三个字:快、乱、丢。
- 快:没有连接建立,没有重传,发就完了
- 乱:后发的包可能先到,顺序不保证
- 丢:网络拥堵直接丢包,应用层不处理就没了
但数据报有个好处——有消息边界。你 sendto 发 100 字节,对端 recvfrom 一次就收 100 字节,不会出现粘包。我做过一个日志采集系统,用的就是 UDP,丢了几个包无所谓,但延迟必须低。
| 对比维度 | 流式 Socket(TCP) | 数据报 Socket(UDP) |
|---|---|---|
| 连接 | 面向连接,先建立连接再通信 | 无连接,直接发数据 |
| 可靠性 | 可靠,丢包重传 | 不可靠,丢包不通知 |
| 消息边界 | 无边界,流式传输 | 有边界,每个包独立 |
| 典型场景 | HTTP、FTP、SSH | DNS、视频直播、游戏 |
Socket API 概览:五个核心函数
写网络程序,你绕不开这五个函数。我按服务端启动的顺序讲,这样好理解。
socket() — 创建插口
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
第一个参数指定协议族,IPv4 就用 AF_INET。第二个参数指定类型,SOCK_STREAM 是 TCP,SOCK_DGRAM 是 UDP。第三个参数一般填 0,让内核自动选协议。
返回值是一个文件描述符。如果返回 -1,说明创建失败。我曾经见过新手不检查返回值,直接拿 -1 去 bind,结果一脸懵。
注意:socket() 返回的 fd 默认是「主动」的,也就是客户端模式。要变成服务端,需要后续操作。
bind() — 绑定地址和端口
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));
bind 就是把 Socket 和具体的 IP、端口绑在一起。不 bind 的话,内核会随机分配端口。服务端必须 bind,否则客户端不知道连哪里。
这里有个细节——htons() 是主机字节序转网络字节序。为什么要有这个?因为不同 CPU 的大小端不一样,网络协议统一用大端。我刚开始写代码时忘了调字节序,结果端口号对不上,排查了半天。
listen() — 开始监听
listen(sockfd, 5);
这个函数把 Socket 从「主动」变成「被动」。第二个参数是连接队列的长度,表示最多能积压多少个未处理的连接请求。
嗯,这里要注意:listen() 不阻塞。它只是告诉内核:「我准备好了,有连接进来就排队」。真正的等待发生在 accept()。
accept() — 接受连接
int client_fd = accept(sockfd, NULL, NULL);
accept() 从已完成连接队列里取出一个连接,返回一个新的 fd。这个新 fd 才是真正用来和客户端通信的。原来的监听 fd 继续等下一个连接。
我遇到过一个问题:高并发下 accept() 返回 -1,错误码是 EAGAIN。这是因为我把 Socket 设成了非阻塞,队列里没连接时直接返回。解决方案?要么轮询,要么用 epoll 等事件驱动。
connect() — 发起连接
connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
这是客户端用的。connect() 会发起三次握手,阻塞直到连接建立或超时。如果对端没在 listen,connect() 会返回 -1,错误码 ECONNREFUSED。
我曾经写过一个批量扫描工具,没设置 connect 超时,结果连一个不存在的 IP 卡了 30 秒。后来用非阻塞 connect + select 解决了。
一张图看懂 Socket 通信流程
下面这张图是我自己画的,把服务端和客户端的完整流程串起来。你对照着看,思路会清晰很多。
我的建议:初学者先别急着看 epoll、多线程这些高级特性。把 socket → bind → listen → accept → recv/send → close 这条线跑通,后面学什么都快。
一个完整的 TCP 回显服务端示例
光说不练假把式。下面这个例子,你复制到 Linux 上就能跑。它做的事情很简单:客户端发什么,服务端原样返回。
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
int main() {
int sockfd, client_fd;
struct sockaddr_in addr;
char buf[1024];
int n;
// 1. 创建 Socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
return 1;
}
// 2. 绑定端口 8080
memset(&addr, 0, sizeof(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");
return 1;
}
// 3. 开始监听
if (listen(sockfd, 5) < 0) {
perror("listen");
return 1;
}
printf("服务端已启动,监听端口 8080...\n");
// 4. 接受连接
client_fd = accept(sockfd, NULL, NULL);
if (client_fd < 0) {
perror("accept");
return 1;
}
// 5. 回显数据
while ((n = recv(client_fd, buf, sizeof(buf), 0)) > 0) {
send(client_fd, buf, n, 0);
}
// 6. 关闭
close(client_fd);
close(sockfd);
return 0;
}
这个例子只处理一个客户端连接,生产环境肯定不够用。但作为理解 Socket 流程的起点,足够了。你可以在另一个终端用 telnet 127.0.0.1 8080 测试,输入什么就返回什么。
避坑:bind 时如果提示「Address already in use」,说明端口被占用了。可以用 setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, ...) 解决。我曾经在调试时频繁重启服务,没加这个选项,每次都要等 2 分钟才能重新绑定。