Socket 网络编程基础
前言
Netty 是一个 Java 网络通信框架,底层依赖操作系统提供的 Socket
系统调用。因此,在《图解 Netty 源码》主线任务的第一篇文章中,我们会首先介绍 Socket
网络编程的基础。
计算机网络有多种分层模型,这里我们选择 TCP/IP 协议分层模型来进行讲解。
TCP/IP 协议分层自上而下包括以下层级:
- 应用层:负责用户交互并处理特定的应用需求,常见协议包括
HTTP
、FTP
、SMTP
等。 - 传输层:负责端到端的数据传输,常用协议为
TCP
和UDP
。 - 网络层:负责跨网络传输数据包,
IP
协议是核心协议。 - 链路层:负责数据帧的传输和物理链路管理,典型协议有以太网等。
如下图所示:

由此可推测,开发者在应用层开发 APP 时,需要使用 操作系统提供的 TCP 或者 UDP 系统调用 来实现网络相关的编程。
那么,什么是这个系统调用?我们又该如何调用它呢?
没错,这就是 Socket
。

原来 Socket
就在这里。
那么,什么是 Socket 呢?
Socket
是应用层与 TCP/IP 协议族通信的中间抽象层,一组接口。在设计模式中,Socket
实际上是一个门面模式的实现,它将复杂的 TCP/IP 协议族隐藏在 Socket 接口背后。对用户而言,只需调用一组简单的接口即可完成通信,Socket
会组织数据,使之符合指定的协议。
你会使用它们吗?
前人已经为我们简化了许多工作,使网络通信变得相对容易。当然,仍然有一些底层细节需要处理。过去听到“Socket 编程”,总觉得它是很高深的知识,但只要理解了 Socket 编程的工作原理,它的神秘面纱就会被揭开。
用一个生活场景来类比:当你打电话给朋友时,首先拨号,朋友接听电话,连接建立成功后你们就可以交谈了。通话结束后,挂断电话结束交流。这个生活场景就类似于 Socket 的工作原理。或许,TCP/IP 协议族的设计灵感就源自生活,也说不定呢。
什么是 Socket?
socket 一词的起源
在计算机组网领域中,socket
一词首次出现在 1970 年 2 月 12 日发布的文献 IETF RFC33 中,撰写者为 Stephen Carr、Steve Crocker 和 Vint Cerf。根据美国计算机历史博物馆的记载,Crocker 写道:“命名空间的元素都可称为套接字接口。一个套接字接口构成一个连接的一端,而一个连接可完全由一对套接字接口规定。”
计算机历史博物馆补充道:“这比 BSD 套接字接口的定义早了大约 12 年。”
前文提到,网络中的进程是通过 socket
进行通信的。那么,究竟什么是 socket 呢?
socket
起源于 Unix,而 Unix/Linux 的一个基本哲学是“一切皆文件”,即所有资源都可以通过“打开 (open
) → 读写 (write/read
) → 关闭 (close
)”的模式进行操作。
可以理解为,socket
就是这种文件操作模式的一个实现:它是一种特殊的文件,允许我们使用各种 socket
函数对其进行操作(如读写 IO、打开和关闭)。接下来,我们将基于C语言进一步介绍这些函数的具体作用。
基本 TCP 套接字编程
先来看看《UNIX网络编程 卷1》中的 基本TCP客户/服务器程序的套接字函数

创建套接字
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
为执行网络 I/O,进程的第一步是调用 socket
函数(本质上是打开网络文件),并指定所需的通信协议类型(如 IPv4 的 TCP、IPv6 的 UDP、Unix 域字节流协议等)。
参数说明:
domain
参数:指定协议族,即你想要使用的协议(IPv4、IPv6 等)
domain | 说明 |
---|---|
AF_INET | IPv4 协议 |
AF_INET6 | IPv6 协议 |
AF_LOCAL | Unix 域协议 |
AF_ROUTE | 路由套接字 |
AF_KEY | 密钥套接字 |
规定:本例中所用的套接字协议为 AF_INET
(即 IPv4 协议)。
type
参数:指定套接字的类型
type | 说明 |
---|---|
SOCK_STREAM | 字节流套接字 |
SOCK_DGRAM | 数据报套接字 |
SOCK_SEQPACKET | 有序分组套接字 |
SOCK_RAW | 原始套接字 |
说明:若使用 TCP 通信,则套接字类型应设为 SOCK_STREAM
;若为 UDP 通信,则使用 SOCK_DGRAM
。
protocol
参数:指定协议类别,如 TCP 或 UDP。一般情况下,该字段设置为 0
即可,表示默认协议。系统会根据前两个参数自动推导出适用的协议。
protocol | 说明 |
---|---|
IPPROTO_TCP | TCP 传输协议 |
IPPROTO_UDP | UDP 传输协议 |
IPPROTO_SCTP | SCTP 传输协议 |
返回值说明:
套接字创建成功则返回文件描述符;若创建失败,返回值为 -1
,同时设置错误码。
绑定端口号
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind
函数用于将协议地址赋予一个套接字。
参数说明:
sockfd
参数:要绑定的文件描述符,即在创建套接字时获得的文件描述符。addr
参数:指向特定协议的地址结构的指针,包含协议族、端口号和 IP 地址等信息(详见下一节关于sockaddr
结构的介绍)。addrlen
参数:该协议地址结构的长度。
返回值说明:
绑定成功则返回 0
,若绑定失败返回 -1
,并设置错误码。
监听套接字
// 开始监听socket (TCP, 服务器)
int listen(int sockfd, int backlog);
listen
函数通常由 TCP 服务器调用,用于向外宣告服务器愿意接受连接请求,并执行以下两项操作:
- 转换为被动套接字:当
socket
函数创建套接字时,它默认是主动套接字,即用于客户端发起连接的套接字。listen
函数将未连接的套接字转换为被动套接字,指示内核接受对该套接字的连接请求。简单来说,服务器调用listen
就是告知客户端它可以接受连接。 - 设置最大连接数:第二个参数指定内核为该套接字排队的最大连接数。
backlog
提供了一个提示,表明系统应为该进程排队的未完成连接请求数量。对于 TCP,默认值为 128。- 当队列满时,系统会拒绝多余的连接请求。因此,
backlog
的值应根据服务器期望的负载和处理能力来选择,其中处理能力指的是可以接受和启动服务的连接数量。
- 当队列满时,系统会拒绝多余的连接请求。因此,
一旦调用了 listen
,套接字便可以接受连接请求。连接请求可通过 accept
函数来处理并建立连接。
返回值:成功返回 0
,失败则返回 -1
。
注意:该函数通常应在调用 socket
和 bind
后使用,并且在调用 accept
之前调用。
为了理解 backlog
参数,我们需要认识到内核为任何给定的 监听套接字 维护两个队列:
- 未完成连接队列(incomplete connection queue):每个这样的 SYN 分节对应队列中的一项,这些分节已由某个客户端发送并到达服务器,但服务器正在等待完成相应的 TCP 三路握手 过程。这些套接字处于 SYN RCVD 状态。
- 已完成连接队列(completed connection queue):每个已完成 TCP 三路握手 过程的客户端对应队列中的一项,这些套接字处于 ESTABLISHED 状态。
backlog
参数主要影响 未完成连接队列 的大小,它定义了内核在接受客户端连接请求时所能排队的最大数量。如果队列已满,额外的连接请求将被拒绝。

每当在 未完成连接队列 中创建一项时,来自 监听套接字 的参数会被复制到即将建立的连接中。连接的创建机制是完全自动的,无需服务器进程的插手。
接受请求
// 接收请求 (TCP, 服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept
函数由 TCP 服务器 调用,用于从已完成连接队列中返回下一个已完成连接。如果已完成连接队列为空,进程将进入阻塞状态。
如果 accept
成功,返回值是内核自动生成的一个全新描述符,表示与所返回客户端的 TCP 连接。通常,我们称其第一个参数为监听套接字描述符(由 socket
创建,并用作 bind
和 listen
的第一个参数),而将返回值称为已连接套接字描述符。
区分这两个套接字非常重要
- 一个服务器通常仅创建一个监听套接字,该套接字在服务器的生命周期内一直存在。
- 内核为每个由服务器进程接受的客户连接创建一个已连接套接字,这意味着其 TCP 三次握手过程已经完成。
- 当服务器完成对某个特定客户的服务时,相应的已连接套接字 就会被关闭。
总的来说,accept
返回的文件描述符是一个新的套接字描述符,连接到调用 connect
的客户端。这个新的套接字描述符与原始套接字(sockfd
)具有相同的套接字类型和地址族。传给 accept
的原始套接字仍然保持监听状态,以接受其他连接请求。
建立连接
// 建立连接 (TCP, 客户端)
#included <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect
函数用于 TCP 客户端 请求 TCP 服务器 建立连接。
参数说明:
- sockfd:由
socket
函数返回的套接字描述符。 - 第二个参数:指向套接字地址结构的指针,表示目标服务器的地址。
- 第三个参数:该结构的大小。
在 connect
中指定的地址是我们希望与之通信的服务器地址。如果 sockfd
没有绑定到一个地址,connect
将为调用者绑定一个默认地址。
返回值说明:
- 成功时返回
0
,出错时返回-1
。
关闭连接
- 客户端调用
close
,表明没有数据需要发送,此时会向服务器发送 FIN 报文,并进入 FIN WAIT 1 状态。 - 服务器接收到 FIN 报文后,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过
read
调用来感知这个 FIN 包。这个 EOF 会被放在已排队的其他已接收数据之后,这意味着服务器需要处理这种异常情况,因为 EOF 表示在该连接上不会再有额外数据到达。此时,服务器进入 CLOSE WAIT 状态。 - 服务器处理完数据后,自然会读取到 EOF,于是也调用
close
关闭它的套接字,这会使得服务器发出一个 FIN 包,之后处于 LAST ACK 状态。 - 客户端接收到服务器的 FIN 包,并发送 ACK 确认包给服务器,此时客户端将进入 TIME WAIT 状态。
- 服务器收到 ACK 确认包后,就进入了最后的 CLOSE 状态。
- 客户端经过 2MSL 时间之后,也进入 CLOSE 状态。
深入 read
&write
read/write的语义:为什么会阻塞?
先从 write
说起:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
首先,write
成功返回,仅意味着 buf
中的数据被复制到了内核中的 TCP 发送缓冲区。至于数据何时被发送到网络、何时被对方主机接收、何时被对方进程读取,系统调用层面并不提供任何保证和通知。
write
在什么情况下会阻塞?
当内核的该 socket
的发送缓冲区已满时。对于每个 socket
,都有其自己的发送(send buffer
)和接收缓冲区(receive buffer
)。从 Linux 2.6 开始,这两个缓冲区的大小由系统自动调整(autotuning
),但通常在默认值和最大值之间浮动。
获取 socket
的发送/接收缓冲区的大小:
sysctl net.core.wmem_default # 126976
sysctl net.core.wmem_max # 131071
已发送到网络的数据仍需要暂存在发送缓冲区中,只有收到对方的 ack
后,内核才会从缓冲区中清除相应数据,为后续发送的数据腾出空间。接收端将收到的数据暂存在接收缓冲区中,自动进行确认。
如果 socket
所在的进程未及时从接收缓冲区中取出数据,最终会导致缓冲区填满。由于 TCP 的滑动窗口和拥塞控制,接收端会阻止发送端向其发送数据。这些控制都发生在 TCP/IP 栈中,对应用程序透明。若应用程序继续发送数据,最终会导致 send buffer
填满,write
调用阻塞。
通常来说
由于接收端进程从 socket
读取数据的速度不及发送端写入 socket
的速度,最终会导致发送端的 write
调用阻塞。
而 read
调用的行为相对容易理解,即从 socket
的接收缓冲区中拷贝数据到应用程序的缓冲区。read
调用阻塞的原因通常是发送端数据未到达。
blocking(默认)和 nonblock 模式下 read/write
行为的区别
将 socket
文件描述符设置为非阻塞模式(nonblock
)是服务器编程中的常见做法。使用阻塞 I/O 并为每个客户端创建一个线程的方式开销巨大,且可扩展性差(带来大量的上下文切换开销)。更为通用的做法是采用 线程池 + 非阻塞 I/O + 多路复用(select
/poll
,以及 Linux 上特有的 epoll
)。
// 设置一个文件描述符为非阻塞模式
int set_nonblocking(int fd) {
int flags;
if ((flags = fcntl(fd, F_GETFL, 0)) == -1)
flags = 0;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
几个重要的结论:
read
调用:read
总是在接收缓冲区有数据时立即返回,而不是等到填满整个缓冲区。只有当接收缓冲区为空时,阻塞模式会等待;在非阻塞模式下,read
会立即返回-1
,并设置errno = EAGAIN
或EWOULDBLOCK
。阻塞模式的
write
调用: 阻塞模式的write
只有在缓冲区足够容纳整个buffer
时才返回。而非阻塞模式的write
会返回已经写入的字节数,接下来的调用会返回-1
(errno = EAGAIN
或EWOULDBLOCK
)。阻塞模式的
write
有一个特例:当write
正在阻塞等待时,如果对方关闭了socket
,write
会立即填满剩余缓冲区并返回写入的字节数。再次调用write
时会失败,并返回connection reset by peer
错误。
read/write
对连接异常的反馈行为
在应用程序层面,TCP 通信实际上是一个完全异步的过程,存在以下不确定性:
- 不确定对方是否收到数据。
- 不知道何时能收到对方数据。
- 不确定通信何时结束(可能由于主动退出、异常退出、机器故障或网络故障)。
对于前两点,可以通过 write() -> read() -> write() -> read() ->...
的序列操作,配合阻塞 read
或非阻塞 read
+ 轮询方式来正确处理。
对于第三点,内核通过 read/write
的结果将事件“通知”应用层。
假设 A 机器上的进程 a
正在和 B 机器上的进程 b
通信,某一时刻 a
阻塞在 socket
的 read
调用上(或在非阻塞模式下轮询 socket
)。当进程 b
终止时,无论应用程序是否显式关闭 socket
,操作系统都会关闭所有文件描述符,并发送一个 FIN
包至对端。
同步通知: 进程 a
在已收到 FIN
的 socket
上调用 read
,若已读完接收缓冲区剩余字节,则返回 EOF:0
。
异步通知: 若进程 a
阻塞在 read
上(接收缓冲区为空时 read
会阻塞),则调用 read
会立即返回 EOF
,并唤醒进程 a
。
异常情况: 如果进程 b
异常终止,操作系统会为其所有打开的 socket
发送 RST
(因为 socket
所属进程已终止)。此时,进程 a
调用 write
会收到 SIGPIPE
信号,默认处理是终止进程,这就解释了进程为何可能会“毫无征兆”地终止。
但是,仅仅通过 read/write
检测异常情况并不完全可靠,因此还需一些额外手段:
TCP 的
KEEPALIVE
功能:ccat /proc/sys/net/ipv4/tcp_keepalive_time # 7200 秒 cat /proc/sys/net/ipv4/tcp_keepalive_intvl # 75 秒 cat /proc/sys/net/ipv4/tcp_keepalive_probes # 9 次
以上参数含义为:
keepalive
每 2 小时(7200 秒)启动一次,发送第一个探测包,若在 75 秒内未收到应答则重发探测包,连续 9 次未应答则认为连接已断开。此时,read
应返回错误。应用层心跳:
在严格的网络程序中,应用层心跳协议是必不可少的。虽然比 TCP 自带的
keepalive
要复杂一些,但更为可控。
简单的 TCP 网络程序
客户端
服务器
总结
Socket 是应用层与 TCP/IP 协议族之间的通信接口,它通过提供简单的 API 隐藏了底层的协议细节。开发者通过调用操作系统的 Socket 系统调用来进行网络编程,进程通过 Socket 与其他进程进行通信。Socket 本质上是一种特殊的文件,遵循文件操作的模式,允许进程进行读写操作。它在网络编程中充当着重要的角色,尤其是 TCP/IP 协议族的通信。
Socket 的创建过程首先通过 socket
函数,指定通信协议类型(如 IPv4 或 IPv6)和套接字类型(如 TCP 或 UDP)。接着,服务器通过 bind
函数将协议地址与套接字绑定,并使用 listen
函数将套接字转换为被动套接字,准备接受客户端连接。客户端通过 connect
函数发起连接请求,服务器通过 accept
函数接收并建立与客户端的连接。连接成功后,客户端和服务器可以进行数据传输,直到使用 close
关闭连接。
在 TCP 网络编程中,服务器通常有两个队列管理连接请求:未完成连接队列和已完成连接队列。listen
函数中的 backlog
参数控制未完成连接队列的大小。当队列满时,额外的连接请求将被拒绝。因此,合理设置 backlog
参数对于系统的连接管理非常重要。
总结而言,Socket 编程通过一组简单的系统调用抽象了网络通信的复杂性,使得开发者可以通过少量的 API 进行复杂的网络交互。了解 Socket 的底层工作原理是高效进行网络编程的基础。