从Socket编程理解TLS连接本质 - 第3节
Socket 是什么?一个朴素的比喻
聊网络编程,绕不开 Socket。很多新手觉得它很玄乎,其实说白了,Socket 就是操作系统提供的一个编程接口。你想想看,两台机器要通信,中间隔着网线、路由器、各种协议栈,难道让你直接操作网卡寄存器?当然不行。
操作系统把网络通信的细节封装了,给你扔出来一个「文件描述符」。你往这个描述符里写数据,数据就通过网络发出去;别人发来的数据,你也从这个描述符里读。就这么简单。
我个人习惯把 Socket 理解成「网络世界的文件句柄」。读写 Socket 和读写本地文件,在 API 层面几乎一样——open、read、write、close。只不过文件的数据在磁盘上,Socket 的数据在网络上。
TCP Socket 的 C/S 模型
TCP 是面向连接的协议,所以它的 Socket 模型天然就是客户端-服务器架构。服务端得先跑起来,等着别人连;客户端主动发起连接。这个模型很成熟,几乎所有互联网应用都在用。
服务端:四步走
服务端的代码流程,我拆成四个核心步骤。嗯,这里要注意,每一步都有坑。
- 创建 Socket —— 调用
socket(),指定协议族和类型。 - 绑定地址 —— 调用
bind(),把 Socket 和 IP、端口绑在一起。 - 监听 —— 调用
listen(),告诉内核:我准备好了,可以接受连接了。 - 接受连接 —— 调用
accept(),阻塞等待客户端上门。
我曾经在 bind 这一步吃过亏。端口被占用了,bind 直接返回 -1,程序崩溃。后来我养成了一个习惯:每次 bind 之前先设置 SO_REUSEADDR 选项,这样即使端口还在 TIME_WAIT 状态,也能重新绑定。
客户端:三步走
客户端更简单,三步搞定:
- 创建 Socket —— 同样
socket()。 - 连接服务端 —— 调用
connect(),传入服务端的 IP 和端口。 - 收发数据 —— 用
send()和recv()干活。
你想想看,客户端不需要 bind,也不需要 listen。它只需要知道服务端在哪,然后敲门就行。内核会自动给客户端的 Socket 分配一个临时端口。
核心 API 速查表
我把最常用的几个 API 整理成了一张表。这张表我建议你收藏,写代码时经常回来翻翻。
| 函数 | 作用 | 关键参数 | 返回值 |
|---|---|---|---|
socket() |
创建 Socket | AF_INET, SOCK_STREAM, 0 | 文件描述符 / -1 |
bind() |
绑定地址和端口 | sockfd, struct sockaddr*, addrlen | 0 / -1 |
listen() |
进入监听状态 | sockfd, backlog | 0 / -1 |
accept() |
接受客户端连接 | sockfd, struct sockaddr*, socklen_t* | 新 fd / -1 |
connect() |
连接服务端 | sockfd, struct sockaddr*, addrlen | 0 / -1 |
send() |
发送数据 | sockfd, buf, len, flags | 发送字节数 / -1 |
recv() |
接收数据 | sockfd, buf, len, flags | 接收字节数 / 0 / -1 |
close() |
关闭 Socket | sockfd | 0 / -1 |
recv() 返回 0 表示对方已经优雅关闭连接。返回 -1 才是出错。很多新手把 0 当成错误处理,结果漏掉了正常的连接关闭事件。
一个完整的 C/S 示例
光说不练假把式。我写一个最简单的回声服务器(Echo Server),客户端发什么,服务端原样返回什么。代码我尽量精简,去掉错误处理,让你看清骨架。
服务端代码
// server.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
int main() {
int server_fd, client_fd;
struct sockaddr_in addr;
char buf[1024];
// 1. 创建 Socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 绑定地址
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
addr.sin_port = htons(8888);
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
// 3. 监听
listen(server_fd, 5);
// 4. 接受连接
client_fd = accept(server_fd, NULL, NULL);
// 5. 收发数据
int n = recv(client_fd, buf, sizeof(buf), 0);
send(client_fd, buf, n, 0);
close(client_fd);
close(server_fd);
return 0;
}
客户端代码
// client.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
int main() {
int sock_fd;
struct sockaddr_in addr;
char *msg = "Hello, Socket!";
char buf[1024];
// 1. 创建 Socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2. 连接服务端
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(8888);
connect(sock_fd, (struct sockaddr*)&addr, sizeof(addr));
// 3. 发送并接收
send(sock_fd, msg, strlen(msg), 0);
int n = recv(sock_fd, buf, sizeof(buf), 0);
buf[n] = '\0';
printf("收到: %s\n", buf);
close(sock_fd);
return 0;
}
TCP 三次握手与 Socket API 的对应关系
很多教材讲三次握手,但从来不告诉你它和 Socket API 怎么对应。我当年学的时候也困惑过:三次握手到底发生在哪个函数里?
其实很简单:
- 第一次握手(SYN):客户端调用
connect()时触发。内核自动发 SYN 包。 - 第二次握手(SYN+ACK):服务端内核收到 SYN,自动回复 SYN+ACK。此时服务端的
accept()还没返回。 - 第三次握手(ACK):客户端内核收到 SYN+ACK,自动发 ACK。此时
connect()返回成功。
你发现没有?三次握手完全由内核自动完成,应用层根本不需要插手。connect() 返回时,连接已经建立好了。accept() 返回时,服务端拿到的是已经完成三次握手的连接。
TCP Socket 核心流程全景图
下面这张图,我画了 TCP Socket 从创建到关闭的完整流程。左边是服务端,右边是客户端,中间是三次握手和四次挥手。你盯着看两分钟,整个模型就刻在脑子里了。
几个常见的坑
做网络编程,有些坑是绕不过去的。我踩过,也看别人踩过。列几个最常见的:
- 端口被占用:服务端崩溃后立即重启,bind 失败。因为端口还在 TIME_WAIT 状态。解决方案:设置
SO_REUSEADDR。 - recv 不保证一次收完:TCP 是流协议,没有消息边界。你发 100 字节,对方可能分两次收到。我曾经在做一个网关时,就因为没处理粘包,导致协议解析全乱套了。
- 阻塞与非阻塞:默认 Socket 是阻塞的。accept、recv、send 都可能卡住。生产环境一般用非阻塞 + epoll/select 多路复用。
- 惊群问题:多线程/多进程同时 accept 同一个监听 Socket,内核会唤醒所有等待者,但只有一个能成功。高并发场景下这是个性能杀手。
小结
Socket 编程的核心就这些东西。一个 Socket 描述符,几行 API 调用,一个 C/S 模型。你把这个模型理解透了,后面学 TLS 就轻松很多——因为 TLS 本质上就是在 TCP Socket 之上加了一层加密层。连接怎么建立、数据怎么收发、连接怎么关闭,这些骨架完全一样。
嗯,这一章就到这。代码你最好自己敲一遍,别光看。敲完跑起来,看到 "Hello, Socket!" 打印出来,那种感觉是不一样的。