socket、bind、listen、connect、accept 执行过程中的用户态/内核态与结构变化
本文围绕
socket、bind、listen、connect、accept
这几个最核心的套接字函数,梳理它们执行时用户进程可见的对象与内核中对应数据结构是如何一步步建立、关联和变化的;同时也顺着这个过程说明什么是用户态、什么是内核态,以及为什么这些调用本质上都是通过系统调用进入内核完成工作的。
很多同学在学习 socket 编程时,容易把这几个函数记成“固定模板”:
1 | listenfd = socket(...); |
客户端则是:
1 | sockfd = socket(...); |
从 API
的角度看,这样记当然没问题;但如果想真正理解“为什么服务端在
accept 之后会拿到一个新的描述符”“为什么
connect 之前客户端通常不必手动
bind”“为什么 listen
之后套接字就变成了监听套接字”,就必须往下看一层:
- 用户态看到的是整数文件描述符 fd;
- 内核态维护的是文件描述符表、文件对象、socket 对象、协议控制块、连接队列等结构;
- 这些函数的核心工作,就是把这些结构创建出来、连起来,并改变它们的状态。
本文以 TCP/IPv4 为主线说明。不同 UNIX/Linux 内核版本在具体结构命名上会有差异,但整体思想是稳定的。
总图:用户态和内核态分别看到什么
先给出一个总的认识:应用程序并不能直接操作网卡、协议栈和连接队列,它只能通过系统调用陷入内核,请内核代为完成。
用户态视角
用户进程通常只能直接接触到这些东西:
- 变量
int sockfd struct sockaddr_in addr- 调用
socket/bind/listen/connect/accept - 用
read/write/send/recv读写数据
也就是说,在用户态,“socket”首先表现为一个文件描述符。
内核态视角
内核中,和一个 socket 相关的典型对象可以粗略理解为:
1 | 进程 task_struct |
这条链路很关键:
- fd 是用户态拿着的“句柄”;
struct file是 VFS 层的通用打开文件对象;struct socket是套接字抽象;struct sock则更靠近具体协议实现(例如 TCP)。
因此,很多 socket 调用本质上就是:
- 用户态传入 fd 和参数;
- 内核根据 fd 找到
struct file; - 再从
struct file找到struct socket/struct sock; - 修改其中的地址、状态、队列或创建新的内核对象。
用户态与内核态
在分析这些函数之前,先把“用户态/内核态”说清楚。
用户态
用户态(user mode)是普通应用程序执行的状态。它的特点是:
- 权限受限;
- 不能直接访问硬件;
- 不能直接操作网卡、页表、调度器等核心资源;
- 访问内核对象必须通过系统调用。
比如下面这句:
1 | sockfd = socket(AF_INET, SOCK_STREAM, 0); |
从 C 代码看只是函数调用,但底层会触发系统调用,CPU 从用户态切到内核态。
内核态
内核态(kernel mode)是操作系统内核执行的状态。它有更高权限,可以:
- 分配和释放内核对象;
- 维护协议栈状态;
- 操作网卡与中断;
- 把网络包放入接收队列;
- 唤醒阻塞在
accept、recv等系统调用上的进程。
一次系统调用发生了什么
以 bind(sockfd, ...) 为例,简化过程如下:
1 | 用户态进程 |
因此,socket、bind、listen、connect、accept
的共同点是:
- 接口在用户态调用;
- 核心工作在内核态完成;
- 返回用户态一个结果(成功/失败、fd、对端地址等)。
五个函数分别做了什么
socket
用户代码
1 | int sockfd = socket(AF_INET, SOCK_STREAM, 0); |
对用户进程来说,结果只是得到一个整数,例如 3。
内核中发生的关键变化
当执行 socket() 时,内核大致会做这些事:
- 为该进程分配一个尚未使用的文件描述符,比如
fd=3; - 创建一个
struct file对象; - 创建一个
struct socket对象; - 为 TCP 分配更底层的协议控制块(可理解为
struct sock); - 把这些对象关联起来;
- 将该 fd 填入进程的 fd 表。
此时套接字通常还没有:
- 本地 IP/端口;
- 对端 IP/端口;
- 监听队列;
- 已建立连接。
它只是一个被创建出来的通信端点骨架。
结构示意
1 | 用户态 |
这一阶段的本质
socket() 的本质不是“建立连接”,而是:
让用户进程获得一个指向内核 socket 对象的文件描述符入口。
所以只 socket() 而不 connect() /
bind() / listen(),并不能完成通信。
bind
用户代码
1 | struct sockaddr_in addr; |
在用户态,我们只是准备了一个地址结构并调用 bind()。
内核中发生了什么
内核接到 bind 后,大致会完成这些动作:
- 根据
sockfd找到对应的 socket; - 从用户态拷贝
sockaddr_in到内核; - 检查地址是否合法;
- 检查该端口是否已被占用,或是否允许复用;
- 把“本地 IP / 本地端口”写入 socket 对应的内核结构;
- 把该 socket 放入对应的绑定哈希表/端口管理结构中,便于后续查找。
这里绑定的到底是什么
bind 绑定的不是“进程”,而是:
某个 socket 内核对象的本地地址信息。
绑定完成后,这个 socket 才真正拥有了“我是谁”的身份,例如:
- 本地 IP:
0.0.0.0 - 本地端口:
8080
INADDR_ANY 的含义
如果服务端绑定的是 INADDR_ANY,内核可以理解为:
这个监听 socket 接收“发往本机任意本地网卡地址、端口为 8080”的连接。
bind 前后结构变化
bind 之前:
1 | TCP sock |
bind 之后:
1 | TCP sock |
为什么客户端常常不手动 bind
因为客户端如果不显式 bind,通常在 connect
时由内核自动分配:
- 一个合适的本地 IP;
- 一个临时端口(ephemeral port)。
所以很多客户端代码直接:
1 | sockfd = socket(...); |
也能正常工作。
listen
用户代码
1 | listen(sockfd, 128); |
从用户态看,sockfd 没变,仍然是同一个 fd。
内核中的关键变化
listen 不会新建一个
fd,也不会建立某个具体连接;它做的核心工作是:
- 检查该 socket 是否适合进入监听状态;
- 将 socket 状态从
CLOSED初始状态转为LISTEN; - 为它建立或启用监听相关队列;
- 记录 backlog 参数,用于限制等待队列容量。
对于 TCP 来说,监听 socket 需要维护至少概念上的两类队列:
- 半连接队列:收到 SYN、尚未完成三次握手的请求;
- 全连接队列:三次握手已经完成、等待应用调用
accept取走的连接。
不同实现细节略有差异,但“监听 socket 管理连接请求队列”这个思想不变。
listen 前后变化图
listen 之前:
1 | fd=3 |
listen 之后:
1 | fd=3 |
listen 的本质
listen() 的本质可以概括为:
把一个已绑定本地地址的主动通信端点,改造成一个专门用于接收入站连接请求的监听端点。
注意这里最容易混淆的一点:
- 监听 socket 本身并不承载具体已建立连接的数据流;
- 它主要负责“接收连接请求”和“派生已连接 socket”。
这正是后面 accept 会返回新
fd的根本原因。
connect
下面切到客户端视角。
用户代码
1 | int sockfd = socket(AF_INET, SOCK_STREAM, 0); |
connect 时内核做了什么
对 TCP 客户端而言,connect 的关键步骤可以概括为:
- 根据
sockfd找到客户端 socket; - 将用户提供的服务器地址拷入内核;
- 如果该 socket 尚未绑定本地地址,则自动选择:
- 出接口对应的本地 IP;
- 一个临时端口;
- 把对端地址写入该 socket;
- 将 TCP 状态改为
SYN_SENT; - 发送 SYN 报文;
- 等待三次握手完成;
- 握手成功后,状态进入
ESTABLISHED,connect返回。
connect 前后的 socket 信息变化
connect 之前:
1 | 客户端 TCP sock |
调用 connect 后,发送 SYN 之前/之后:
1 | 客户端 TCP sock |
三次握手完成后:
1 | 客户端 TCP sock |
从用户态/内核态角度看 connect
对用户来说,connect 像是“去连服务器”。
对内核来说,它实际上至少做了三件事:
- 确定本地通信身份;
- 记录远端通信身份;
- 驱动 TCP 状态机开始握手。
所以 connect 并不只是“写入一个地址”,而是让这个
socket 从未连接对象,进入 TCP 建连状态机。
服务端在
connect 到来时,监听 socket 内核里发生什么
这一段是理解 listen + accept 的关键。
当客户端发送 SYN 到达服务端后,服务端并不是直接让监听 socket 变成“已连接”。而是由内核基于监听 socket 衍生出与连接相关的请求对象/子 socket。
可以把过程粗略理解成下面这样:
监听 socket 保持 LISTEN
1 | 监听 socket(一直存在) |
收到 SYN 后,内核创建“连接请求项”
1 | 监听 socket |
三次握手完成后,连接进入全连接队列
1 | 监听 socket |
这里非常重要:
监听 socket 自己仍然是 LISTEN 状态,不会变成 ESTABLISHED。
它负责“招待下一批连接”;具体某个客户端的连接,放在全连接队列里的子 socket里。
accept
用户代码
1 | int connfd = accept(listenfd, NULL, NULL); |
很多初学者最疑惑的就是这里:
listenfd已经是一个 socket 了;- 为什么
accept还要返回一个新的connfd?
答案就在内核结构变化上。
accept 执行时的内核动作
当服务端调用 accept(listenfd, ...) 时,内核大致会:
- 根据
listenfd找到监听 socket; - 查看其全连接队列是否有已完成握手的连接;
- 如果没有,则当前进程睡眠/阻塞,等待连接到来;
- 如果有,则从全连接队列取出一个已建立连接的子 socket;
- 为当前进程分配一个新的 fd,例如
fd=4; - 创建新的
struct file(或建立对应文件对象关系); - 让这个新的 fd 指向那个“已建立连接的子 socket”;
- 将
4返回给用户态,作为connfd。
因此:
listenfd继续监听新的连接;connfd专门和某一个客户端通信。
accept 前后的图
accept 之前:
1 | 当前进程 fd 表 |
accept 之后:
1 | 当前进程 fd 表 |
accept 的本质
accept() 的本质不是“让监听 socket 建立连接”,而是:
从监听 socket 的已完成连接队列中取出一个连接,并为它在当前进程中安装一个新的文件描述符。
这就是为什么服务器通常是:
- 用
listenfd做accept; - 用
connfd做read/write。
完整生命周期图
下面把服务端与客户端串在一起看。
服务端路径
1 | socket() |
客户端路径
1 | socket() |
双方配合图
sequenceDiagram
participant C as 客户端进程
participant CK as 客户端内核
participant SK as 服务端内核
participant S as 服务端进程
C->>CK: socket()
CK-->>C: sockfd
S->>SK: socket()
SK-->>S: listenfd
S->>SK: bind(listenfd, 0.0.0.0:8080)
S->>SK: listen(listenfd, backlog)
C->>CK: connect(sockfd, 192.168.1.10:8080)
CK->>CK: 自动选择本地 IP/临时端口
CK->>SK: SYN
SK->>SK: 为监听 socket 创建请求项/子 socket
SK->>CK: SYN+ACK
CK->>SK: ACK
SK->>SK: 子 socket 进入全连接队列
S->>SK: accept(listenfd)
SK-->>S: connfd
结构变化总结
下面用一张表把每个函数对“用户对象”和“内核对象”的影响压缩总结一下。
| 函数 | 用户态看到的变化 | 内核态发生的关键变化 |
|---|---|---|
socket |
获得一个新的 fd | 创建
struct file、struct socket、协议控制块;socket
初始状态建立 |
bind |
fd 不变,只是调用成功 | 给 socket 写入本地 IP/端口,并登记到绑定表 |
listen |
fd 不变,成为监听 fd | socket 状态变为 LISTEN,建立/启用监听相关队列 |
connect |
fd 不变,调用返回后可通信 | 必要时自动绑定本地地址,设置对端地址,驱动 TCP 三次握手,状态进入
ESTABLISHED |
accept |
返回新的 fd | 从监听 socket 的已完成队列中取出子 socket,并为其分配新的 fd |
小结
如果只记 API 顺序,socket 编程会显得像“背模板”;但从用户态/内核态和内核结构变化的角度看,这几个函数的职责其实非常清晰:
socket:创建通信端点,并把它暴露成一个 fd;bind:为这个端点确定本地身份;listen:把它变成监听端点,并维护连接队列;connect:让客户端端点带着本地/远端地址进入 TCP 建连状态机;accept:从监听端点管理的已完成连接中取出一个具体连接,并返回新的 fd。
其中最关键的认识有两点:
- 用户态拿到的只是 fd,真正的 socket 状态和连接队列都在内核里。
- 服务端的监听 socket 不负责具体数据通信,真正通信的是
accept返回的已连接 socket。
理解了这一点,再去看 epoll、非阻塞 I/O、TCP
状态机、连接队列溢出、SO_REUSEADDR
等主题,就会顺畅很多。