操作系统学习笔记 · 第 22 课 · Socket 编程基础
从这一课起,我们进入第六阶段:网络编程。前面全是"单机"视角——进程、内存、磁盘,都是自己跟自己玩。但从现在起,视野要扩展到网络:一个程序,怎么跟另一台机器上的另一个程序对话?"连接"到底意味着什么?这一课,我们从最基础的 socket 讲起,亲手写一个能跑通的 TCP echo 服务器。
从"打电话"说起
你想跟远方的朋友说话,需要什么?一部电话机。
你拿起电话、拨号、等对方接、然后开始说话——你根本不关心信号是怎么经过基站、光纤传过去的。你只做两件事:拨号 + 说话。
socket(套接字)就是那部电话机:程序用它来"拨号 + 收发数据",至于数据在下面怎么走(IP、以太网、光纤),一概不用管。
22.1 网络分层与 socket 的位置
一句话理解:OSI/TCP-IP 分层里,socket 是应用层和传输层之间的"插座"——程序不用管下面 IP、以太网怎么传,只要"插进 socket 读写字节"即可。
网络通信被分成好几层(OSI 七层 / TCP-IP 四层),每一层只负责自己的事:
- 最底下是物理层(网线、光纤)、链路层(以太网)、网络层(IP,负责"找到对方");
- 中间是传输层(TCP/UDP,负责"可靠地传");
- 最上面是应用层(你的程序)。
socket 就坐在应用层和传输层之间,像墙上的一个插座:
你的程序只要"插进 socket,然后 read/write 字节"就行了。下面 IP 怎么寻址、以太网怎么成帧、数据包怎么在路由器间跳转——通通不用管。
22.2 TCP vs UDP
传输层有两大"传法",先分清:
| TCP | UDP | |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠、有序、不丢不重 | 不可靠、可能丢/乱 |
| 速度 | 慢(要确认重传) | 快 |
| 场景 | 网页、文件、聊天 | 视频、游戏、DNS |
一句话理解:TCP 是"打电话"(要拨通、确认对方在听),UDP 是"发短信"(发出去就完事,不保证收到)。
- TCP:先建立连接(三次握手),之后像一条"可靠管道",数据有序、不丢、不重。代价是要确认、要重传,慢一点。适合网页、文件传输、聊天——一个字节都不能错的场景;
- UDP:无连接,直接把数据包扔出去,不保证到达、不保证顺序。好处是快。适合视频、游戏、DNS——丢了就丢了,快更重要的场景。
这一课我们聚焦 TCP,因为"连接"这个概念最值得深挖。
22.3 socket 是什么
一句话理解:socket = 一个五元组 (协议, 本地IP, 本地端口, 对端IP, 对端端口) 的句柄,程序通过对它 read/write 来收发数据。一个 socket,本质是一个句柄(就是一个数字,像文件描述符那样),但它背后绑定着五个信息:
(协议, 本地IP, 本地端口, 对端IP, 对端端口)这五个信息唯一确定了一条连接。程序拿到这个 socket,对它 read 就是"收数据",write 就是"发数据"——跟读写文件一模一样(还记得"一切皆文件"吗?socket 也是文件的一种)。
例子:服务器监听 0.0.0.0:8080,客户端连过去后,双方各拿到一个 socket,互发字节就像读写文件。"收发数据" = "读写 socket"。22.4 服务器端的固定套路
服务器端有个万年不变的固定流程,五步:
一句话理解:服务器走固定五步——socket(造插座)→bind(占个端口)→listen(开始接客)→accept(接受连接)→read/write(收发数据)。
socket:造一个"插座";bind:把这个插座绑定到一个端口上(比如 8080),相当于"公布自己的门牌号";listen:开始监听,等待别人连过来;accept:接受一个进来的连接,返回一个新的 socket;read/write:用这个新 socket 收发数据。
关键点:accept 返回的是一个新的 socket,专门服务这一个客户端;原来的监听 socket 继续监听。所以一个服务器能同时接多个客户端——来一个,分配一个新 socket 伺候着,老的继续等下一个。22.5 三次握手在代码里的体现
"连接"是怎么建立的?这里有个经典概念——三次握手:
一句话理解:客户端connect发起,内核完成 SYN→SYN-ACK→ACK 三次握手;服务器端accept拿到的是握手已完成的连接。所以"连接"不是accept那一瞬间才建立,而是内核已经握手好了等你去取。
三次握手(简化):
- 客户端发 SYN:"我想连你";
- 服务器回 SYN-ACK:"收到,可以连";
- 客户端再发 ACK:"好的,开始传数据"。
这三步完成后,连接就建立了。在代码里的对应:
- 客户端调
connect,内核帮它完成这三次握手; - 服务器调
accept,拿到的是已经握手完成的连接。
所以"连接"不是在accept那一瞬间才建立的——内核早就握手好了,accept只是"把已经建好的连接取出来"。
可以用 ss -tan 看到两种状态:
- LISTEN:服务器在监听,等着被连;
- ESTAB:连接已建立。
动手实验:C echo 服务器 + Python 客户端
C 服务器:
// lesson22_server.c —— 最简单的 TCP echo 服务器
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(void) {
int srv = socket(AF_INET, SOCK_STREAM, 0); // 1. 造插座
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
addr.sin_port = htons(8080);
bind(srv, (struct sockaddr*)&addr, sizeof(addr)); // 2. 占端口
listen(srv, 5); // 3. 开始监听
printf("服务器监听 8080,等待连接……\n");
int cli = accept(srv, NULL, NULL); // 4. 接受连接
printf("有客户端连进来了!\n");
char buf[256];
while (1) {
int n = read(cli, buf, sizeof(buf) - 1); // 5. 收
if (n <= 0) break; // 对方断开
buf[n] = '\0';
printf("收到:%s", buf);
write(cli, buf, n); // 原样回(echo)
}
close(cli); close(srv);
return 0;
}Python 客户端:
# lesson22_client.py —— Python 客户端(对照)
import socket
c = socket.socket()
c.connect(("127.0.0.1", 8080)) # 三次握手在这里完成
c.sendall(b"hello os\n") # 发
print("收到回显:", c.recv(1024).decode(), end="")
c.close()运行:
gcc lesson22_server.c -o server && ./server # 终端 1
python3 lesson22_client.py # 终端 2观察:服务器打印"收到:hello os",客户端收到同样的字——这就是 echo(回声)。你发出什么,对方原样回给你。
这就是网络编程的"hello world"。别小看它——TCP echo 是所有网络服务的最小骨架,理解它,后面 Nginx、Redis 的原理就都通了一半。
深入点:两个进阶话题
① bind 的"端口占用"
端口是稀缺资源。服务器重启时,常报一个经典错误:
Address already in use本质:上一个连接还处于 TIME_WAIT 状态(主动关闭方要等一段时间确认对方收到最后的 ACK),端口还没释放。用 SO_REUSEADDR 选项可缓解——允许复用处于 TIME_WAIT 的端口。② 字节序(htonl / htons)
代码里的 htons(8080) 是什么意思?
网络传输统一用大端(高字节在前),而你的主机可能是小端(低字节在前)。所以端口、IP 这些数字,发送前要用htons(host to network short)/htonl(long)转换,否则端口号会"反"——你以为在监听 8080,实际别人看到的是另一个数。
小结与思考题
这一课,我们迈出了网络编程第一步:
- socket:应用层和传输层之间的"插座",五元组唯一标识一条连接,读写它就像读写文件;
- TCP vs UDP:可靠但慢(打电话)vs 快但不可靠(发短信);
- 服务器五步:socket → bind → listen → accept → read/write;
- 三次握手:连接是内核先握手好的,
accept只是"取出来"。
留三个问题:
- TCP 和 UDP 的核心区别?
- 服务器端
socket/bind/listen/accept各做什么? - 为什么
accept返回的是"新 socket",而不是复用监听 socket?
这一课的服务器有个明显毛病:一次只能服务一个客户端。第一个客户端连进来不说话,服务器就永远卡在 read,其他人都进不来。怎么破?——这正是下一课的主题:网络 I/O 模型。我们要让一个进程同时伺候成千上万个客户端,看看 select、epoll 这些"多路复用"武器是怎么做到的。