目录
- 基于 TCP 协议的 Socket 程序调用过程
- 基于 UDP 协议的 Socket 程序函数调用过程
- 服务器如何接入更多的项目
- 构建高并发服务端:从多进程到 IO 多路复用
在前面,我们已经介绍了 TCP 和 UDP 协议,但还没有实践过。接下来这一节,我们将讲解如何基于 TCP 和 UDP 协议进行 Socket 编程。
在学习 TCP 和 UDP 协议时,我们分为了客户端和服务端,编写程序时同样遵循这种划分。
Socket 这个名字非常形象,可以理解为“插口”或“插槽”。虽然我们是编写软件程序,但可以把它想象成两端用网线连接,一头插在客户端,另一头插在服务端,通过这条“网线”进行通信。因此,在通信开始之前,客户端和服务端都需要建立一个 Socket。
那么,创建 Socket 时需要设置哪些参数呢?
Socket 编程是端到端的通信,通常我们无法感知数据在中间经过了多少局域网或路由器。因此,Socket 能够设置的参数,主要涉及网络层和传输层。
在网络层,需要指定 IP 协议的版本:IPv4 或 IPv6,分别对应设置为 AF_INET 和 AF_INET6。在传输层,需要选择使用的协议:
- 如果使用的是基于数据流的 TCP 协议,则设置为 SOCK_STREAM;
- 如果使用的是基于数据报的 UDP 协议,则设置为 SOCK_DGRAM。
这样,Socket 的基本参数就完成了配置,客户端和服务端之间便可以通过这个“插槽”开始通信了。
基于 TCP 协议的 Socket 程序调用过程
在创建了 Socket 之后,TCP 和 UDP 的后续流程稍有不同,这里我们先来看基于 TCP 协议的流程。
-
服务端:监听端口
服务端首先需要监听一个端口。通常会先调用 bind 函数,将 Socket 绑定到一个指定的 IP 地址和端口号。为什么需要端口?
因为你写的是一个应用程序,当网络数据包到达时,内核需要通过 TCP 头中的端口号,找到对应的应用程序并将数据传递给它。为什么需要 IP 地址?
一台机器可能有多个网卡,也就可能有多个 IP 地址。通过 bind,你可以选择监听所有网卡的地址,也可以只监听某一个网卡的地址,从而只接收发给该网卡的网络数据包。 -
服务端:进入监听状态
绑定了 IP 和端口后,服务端调用 listen 函数开始监听连接。这时,服务端进入 TCP 状态图中的 LISTEN 状态,准备接受客户端的连接请求。在内核中,为每个监听 Socket 维护两个队列:
已建立连接队列:保存已经完成三次握手的连接,此时这些连接处于 ESTABLISHED 状态。
未完成连接队列:保存还在三次握手阶段的连接,此时这些连接处于 SYN_RCVD 状态。 -
服务端:接受连接
服务端调用 accept 函数,从 已建立连接队列 中取出一个已完成的连接进行处理。如果队列为空,则 accept 会阻塞,等待新的连接完成。 -
客户端:发起连接
客户端通过 connect 函数向服务端发起连接:在参数中指定服务端的 IP 地址和端口号。
内核会给客户端分配一个临时端口,完成连接所需的信息。
发起三次握手,等待服务端响应。
一旦握手完成,服务端的 accept 函数会返回一个 新的 Socket,用于后续数据的读写。 -
监听 Socket 和已连接 Socket 的区别
这是一个重要的知识点:监听 Socket:用于监听客户端连接,它负责接收连接请求,处于 LISTEN 状态。
已连接 Socket:用于真正的数据传输,在连接建立完成后,进入 ESTABLISHED 状态。
换句话说:监听 Socket 接收连接。
已连接 Socket 处理具体的通信。 -
数据读写
连接建立后,服务端和客户端通过 read 和 write 函数实现数据传输。这种操作与对文件流的读写非常类似。
基于 TCP 协议的 Socket 程序函数调用过程如下图:
总结:
-
服务端流程:
创建 Socket。
调用 bind 绑定 IP 地址和端口。
调用 listen 进入监听状态。
调用 accept 接受客户端连接,获得新的 Socket 处理通信。 -
客户端流程:
创建 Socket。
调用 connect 发起连接,指定服务端的 IP 和端口。
等待三次握手完成。 -
数据传输:
双方通过 read 和 write 函数进行数据交换,直至通信结束。
说 TCP 的 Socket 就是一个文件流,是非常准确的。因为,Socket 在 Linux 中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。
在内核中,Socket 是一个文件,那对应就有文件描述符。每一个进程都有一个数据结构 task_struct,里面指向一个文件描述符数组,来列出这个进程打开的所有文件的文件描述符。文件描述符是一个整数,是这个数组的下标。
这个数组中的内容是一个指针,指向内核中所有打开的文件的列表。既然是一个文件,就会有一个 inode,只不过 Socket 对应的 inode 不像真正的文件系统一样,保存在硬盘上的,而是在内存中的。在这个 inode 中,指向了 Socket 在内核中的 Socket 结构。
在这个结构里面,主要的是两个队列,一个是发送队列,一个是接收队列。在这两个队列里面保存的是一个缓存 sk_buff。这个缓存里面能够看到完整的包的结构。
Socket 结构如下图
基于 UDP 协议的 Socket 程序函数调用过程
对于 UDP 来讲,过程有些不一样。UDP 是没有连接的,所以不需要三次握手,也就不需要调用 listen 和 connect,但是,UDP 的的交互仍然需要 IP 和端口号,因而也需要 bind。UDP 是没有维护连接状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用 sendto 和 recvfrom,都可以传入 IP 地址和端口。
基于 UDP 协议的 Socket 程序函数调用过程如下图:
服务器如何接入更多的项目
会了基本的 Socket 函数之后,你就可以轻松写出一个网络交互程序。像之前介绍的过程那样,建立连接后,进入一个 while
循环:客户端发送数据,服务端接收并处理,然后服务端响应,客户端接收。这种方式非常基础,但却有明显的局限性,因为它几乎只能实现一对一的通信。
如果一个服务器只能服务一个客户,那显然是不现实的。这就像一个老板开了公司,只有自己一个人服务客户,只能服务完一家再接下一家,这样的效率很低,也无法实现规模化。
作为老板,你肯定会想:最多能接多少个项目呢?这就需要计算一下服务器的理论最大连接数。TCP 连接是由一个四元组标识的:{本机 IP, 本机端口, 对端 IP, 对端端口}
。对于服务器端,通常固定监听一个本地端口等待客户端连接请求,因此服务端的 TCP 连接四元组中,只有对端的 IP 和端口是变化的。基于此,服务器的最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数。
对于 IPv4,客户端的 IP 数最多为 2^32
,端口数最多为 2^16
,因此理论上单台服务器的最大 TCP 连接数为 2^48
。
然而,实际中服务器的最大并发连接数远远达不到理论值,主要受到以下两个限制:
- 文件描述符限制:在 Linux 中,Socket 是文件的一种,每个 TCP 连接都需要占用一个文件描述符。文件描述符的数量受操作系统限制,可以通过
ulimit
调整其上限。 - 内存限制:每个 TCP 连接都会占用一定的内存,包括内核中的数据结构和 Socket 缓存等。服务器的内存总量决定了可支持的最大连接数。
因此,作为服务器的管理者,为了在有限的资源下接更多的项目,需要优化资源使用,减少每个连接的资源消耗。常见的优化方法包括:
- 提高文件描述符的上限,通过修改
ulimit
和系统配置支持更多连接。 - 使用更轻量的协议(如 HTTP/2 或 QUIC)来降低单个连接的资源开销。
- 实现异步 I/O 或使用事件驱动的网络模型(如 epoll、IOCP 或 libuv)以提高并发性能。
- 通过负载均衡分流到多台服务器,分散单台机器的连接压力。
构建高并发服务端:从多进程到 IO 多路复用
在现代网络编程中,设计一个能够支持大量连接的高并发服务端是一个常见挑战。本文将从多进程、多线程到 IO 多路复用逐步介绍高并发服务端的几种实现方式,以及它们的优劣和适用场景。
1、多进程模型:将任务外包给“子公司”
多进程模型是高并发服务端的早期解决方案。每当一个客户端发起连接,服务端就通过 fork
创建一个子进程,专门处理这个连接,父进程继续监听新的客户端请求。
工作原理
- 服务端通过
bind
和listen
函数监听某个端口。 - 每当有客户端发起连接,服务端通过
accept
函数接受连接。 - 调用
fork
创建一个子进程,子进程复制父进程的文件描述符,并用新的 Socket 与客户端通信。 - 子进程完成任务后退出,父进程继续等待其他连接。
优点
- 简单易用:代码逻辑清晰,子进程隔离性强,互不干扰。
- 可靠性高:子进程出问题不会影响父进程和其他子进程。
缺点
- 开销大:每次创建进程需要分配新的内存和资源,操作系统的开销较高。
- 资源有限:受文件描述符数量和内存限制,无法处理大量连接。
- 上下文切换开销:进程间切换开销较高,处理高并发性能有限。
适用场景
- 小规模并发服务。
- 每个任务独立性强、资源需求大的场景。
2、多线程模型:组建“项目组”完成任务
相比多进程模型,多线程模型更加轻量。每当有客户端连接,服务端通过创建线程来处理任务,而线程之间共享同一进程的资源(如文件描述符和内存)。
工作原理
- 服务端监听端口并接受连接。
- 每当有客户端连接,创建一个新线程(
pthread_create
)来处理该连接。 - 新线程通过已连接的 Socket 与客户端通信。
- 线程完成任务后退出或返回线程池。
优点
- 轻量化:线程共享进程资源,创建和销毁的开销远小于进程。
- 并发能力强:能够比多进程支持更多连接。
缺点
- 资源竞争:线程共享内存,需要同步机制(如锁)避免竞争,增加了编程复杂性。
- 线程数量有限:线程过多时,操作系统资源耗尽,无法支持更高并发。
- 调试复杂:线程共享资源容易导致难以发现的竞争和死锁问题。
适用场景
- 中小规模并发服务。
- 每个任务需要较少资源,且线程间需要共享数据的场景。
3、 IO 多路复用:一个“项目组”管理多个任务
多进程和多线程模型的一个关键问题在于每个连接需要一个独立的处理单元(进程或线程),这对系统资源是一个极大的浪费。IO 多路复用则通过一个线程同时管理多个连接,大幅提升了资源利用率。
工作原理
- 服务端通过
select
、poll
或epoll
函数管理多个文件描述符。 - 将所有连接的 Socket 文件描述符放入一个集合。
- 使用 IO 多路复用函数监听文件描述符的状态变化。
- 如果某个 Socket 准备好(可读或可写),就进行处理。
- 处理完成后,继续监听下一个事件。
优点
- 高效:一个线程管理多个连接,减少了线程/进程的创建开销。
- 灵活:支持动态管理 Socket 文件描述符集合。
- 扩展性强:能够轻松支持数万甚至更多连接(如使用
epoll
)。
缺点
- 复杂性高:程序需要设计事件驱动机制,逻辑复杂。
- CPU 密集型:如果任务本身较复杂(如大量计算),单线程可能成为瓶颈。
适用场景
- 大规模高并发场景(如聊天系统、消息推送)。
- 每个任务只需较少计算且连接时间较长的场景。
4、IO 多路复用的进化:从 select
到 epoll
select
- 通过文件描述符集合管理多个 Socket。
- 需要每次轮询所有文件描述符,效率低,受限于
FD_SETSIZE
。
poll
- 改进了
select
,去掉了文件描述符集合的限制。 - 仍需轮询,效率有限。
epoll
- 采用事件通知机制(callback),避免了轮询。
- 支持动态添加/移除文件描述符,监听效率高。
- 被称为解决 C10K(同时支持 1 万个连接)问题的利器。
核心操作
epoll_create
:创建 epoll 实例。epoll_ctl
:向 epoll 实例中添加或删除文件描述符。epoll_wait
:阻塞等待事件发生。
5、 综合方案:线程池 + IO 多路复用
单纯使用 IO 多路复用在任务较重时可能会出现单线程瓶颈。结合线程池可以进一步提升性能:
- 主线程使用
epoll
监听所有连接。 - 一旦某个连接有事件发生,将任务分发给线程池中的工作线程处理。
- 主线程继续监听其他连接。
优点
- 充分利用 IO 多路复用的高效事件通知机制。
- 减少线程创建销毁的开销,避免线程过多导致的资源争抢。
- 兼顾高并发和任务处理性能。
总结
- 多进程适用于简单的小规模并发任务,但资源开销较大。
- 多线程比多进程更轻量,但需要同步机制处理资源竞争。
- IO 多路复用是应对高并发的关键技术,特别是
epoll
提供了高效的事件通知机制。 - 线程池 + IO 多路复用是目前高并发服务端的主流方案,兼具高效和灵活。