Loading

网络协议-传输层协议-Socket编程

套接字底层原理

套接字简介

位于应用层的应用程序在基于 TCP 协议或 UDP 协议进行通信时,需要用到操作系统提供的类库,这种类库一般称为 API(Application Programming Interface,应用编程接口)。

使用 TCP 或 UDP 时,又会广泛使用到 Socket(套接字)API,Socket 原本是由 BSD UNIX 开发的,但是后来被移植到 Windows 的 Winsock 以及嵌入式系统中。应用程序利用 Socket,可以设置对端的 IP 地址、端口号,并实现数据的接收和发送:

下面我们分别以 TCP 和 UDP 为例,详细介绍 Socket 的底层原理和相关 API 函数。我们先从较为复杂的 TCP 开始。

TCP 套接字

TCP 的服务端要先监听一个端口,一般是先调用 bind 函数,给这个 Socket 赋予一个 IP 地址和端口。

当服务端有了 IP 和端口号,就可以调用 listen 函数进行监听。在 TCP 的状态图里面,有一个 listen 状态,当调用这个函数之后,服务端就进入了这个状态,这个时候客户端就可以发起连接了。

在操作系统内核中,为每个 Socket 维护两个队列。一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于 established 状态;一个是还没有完全建立连接的队列,这个时候三次握手还没完成,处于 syn_rcv 的状态。

接下来,服务端调用 accept 函数,拿出一个已经完成的连接进行处理。如果还没有完成,就要等着。

在服务端等待的时候,客户端可以通过 connect 函数发起连接。先在参数中指明要连接的 IP 地址和端口号,然后开始发起三次握手,操作系统会给客户端分配一个临时的端口。一旦握手成功,服务端的 accept 就会返回另一个 Socket 用于传输数据。

这里需要注意的是,负责监听的 Socket 和真正用来传数据的 Socket 是两个,一个叫作监听 Socket,一个叫作已连接 Socket。

连接建立成功之后,双方开始通过 readwrite 函数来读写数据,就像往一个文件流里面写东西一样。

下面这个图就是基于 TCP 协议的 Socket API 函数调用过程:

说 TCP 的 Socket 就是一个文件流,是非常准确的。因为,Socket 在 Linux 中就是以文件的形式存在的。除此之外,还存在文件描述符。写入和读出,也是通过文件描述符。

注:如果你留心过 Nginx 里面 PHP-FPM 的配置,就会发现有两种方式将 PHP 动态请求转发给 PHP-FPM,一种是 IP 地址+端口号,例如:127.0.0.1:9000,一种是 Socket 文件,例如:unix:/run/php/php7.1-fpm.sock。这里也可以表明,Socket 在 Linux 中确实以文件形式存在,由于不需要建立额外的网络请求,所以后者效率更高,但是由于是本地文件,所以不能跨机器访问,如果 Nginx 和 PHP-FPM 部署在不同的机器,只能通过前一种方式转发请求。

UDP 套接字

基于 UDP 的 Socket 编程要简单一些,因为 UDP 通信无需建立连接,所以不需要三次握手,也就不需要调用 listenconnect 函数,但是,UDP 的的交互仍然需要 IP 和端口号,因而也需要 bind。UDP 是没有维护连接状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,调用 sendtorecvfrom,都可以传入 IP 地址和端口。

服务器如何提高并发量

我们以 Web 请求为例,介绍如何让服务器同时处理更多请求,提高并发量。Web 请求一般都是 HTTP 请求,而 HTTP 协议又是基于 TCP 的,所以,我们主要探讨如何让服务器同时处理更多 TCP 连接请求。

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。服务器端 TCP 连接四元组中只有对端 IP 和对端端口(即客户端IP和端口)是可变的,因此,最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数。对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。

当然,服务端最大并发 TCP 连接数远不能达到理论上限。首先主要是文件描述符限制,按照前面介绍的原理,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;另一个限制是内存,按上面的数据结构,每个 TCP 连接都要占用一定内存,操作系统是有限的。

为了尽可能多的让服务器提供更多连接,处理更多请求,通常有以下几种解决方案:

1、多进程

当有新的请求进来,fork出一个子进程,让子进程处理该请求,提高并发量。

2、多线程

进程开销太大,线程则轻量级的多,所以我们还可以通过在进程中创建新的线程来处理请求。

上面基于进程或线程的模型还是有问题,因为每新进来一个 TCP 连接请求,就需要分配一个进程或线程,从而引发著名的 C10K 问题(一台机器要维护 1 万个连接,就要创建 1 万个进程或者线程,操作系统是无法承受的。如果维持 1 亿用户在线需要 10 万台服务器,成本也太高了),为此,又诞生了一种新的技术 —— 多路 IO 复用。

3、多路IO复用

所谓多路 IO 复用可以简单理解为一个线程维护多个 Socket(前面多进程或多线程都是一个进程或线程维护一个 Socket),这也有两种实现方式:轮询和事件通知。

因为 Socket 在 Linux 系统中以文件描述符形式存在,所以我们把一个线程维护的所有 Socket 叫做文件描述符集合,所谓轮询就是调用内核的 select 函数监听文件描述符集合是否有变化,一旦有变化,就会依次查看每个文件描述符,对那些发生变化的文件描述符进行读写操作,然后再调用 select 函数监听下一轮的变化。

显然,轮询的效率有点低,因为每次文件描述符集合有变化,都要将全部 Socket 轮询一遍,这大大影响了系统能够支撑的最大连接数。如果改成事件通知的方式,情况要好很多。所谓事件通知,就是某个文件描述符发生变化,调用 epoll 函数主动通知。这种方式使得监听的 Socket 数据增加的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常多。上限就为系统定义的、进程打开的最大文件描述符个数。

因此,epoll 被称为解决 C10K 问题的利器。

关于更底层的实现原理我们将留到后面讲 Nginx 的时候深入展开。

C10K 问题

在做技术规划和架构设计的时候,常常告诫技术人员,不要做过度设计,如果咱们只有1万用户,先别去操百万用户在线的心。淘宝那么大,也是从 Apache、PHP、MySql 发展起来的,没人能预见到淘宝会发展到这样一个规模,一旦发展起来,业务的爆发性增长会驱动技术的迅速发展,在业务规模还不及格的时候,不用为技术的未来担心。

这个思路在业务领域不会有太大的问题,因为需求的变化实在是太快了,需要时时去应对。但在底层技术的发展上,我们就有可能遭到「短视」的报复,比如:这个数据长度不会超过16位吧,这个程序不可能使用到2000年吧。于是就有了千年虫的问题,也有了 C10K 的问题。

C10K 就是 Client 10000 问题,即「在同时连接到服务器的客户端数量超过 10000 个的环境中,即便硬件性能足够, 依然无法正常提供服务」,简而言之,就是单机1万个并发连接问题。这个概念最早由 Dan Kegel 提出并发布于其个人站点( http://www.kegel.com/c10k.html )。

为什么会这样呢?因为计算机的上古时代,比如没有网络的 PC 时代,不会有程序员高瞻远瞩的预测到互联网时代的来临,也不会想到一台服务器会创建那么多的进程,即使在互联网初期,一台服务器能有100个在线用户已经是不得了的事情了。甚至,他们在设计 Unix 的 PID 的时候,采用了有符号的16位整数,这就导致一台计算机上能够创建出来的进程无法超过32767个。而计算机自己也得运行一些后台进程,这样应用软件能够创建的进程数就更少了。

当然,这个问题随着技术的发展很快就解决了,现在大部分的个人电脑操作系统可以创建64位的进程,由于数据类型所带来的进程数上限消失了,但是我们依然不能无限制的创建进程,因为随着并发连接数的上升会占用系统大量的内存,同样会造成系统的不可用。

操作系统里内存管理的主要作用是,进程请求内存的时候为其分配可用内存,进程释放后回收内存,并监控内存的使用状况。为了提高内存的使用率,现代操作系统需要程序能够共享内存,并且内存的限制对开发者透明,有些程序占用了内存空间,但不一定是一直使用的,这样可以把这部分内存数据序列化到磁盘上,需要的时候再加载到内存里,这样内存资源永远会给最需要的程序使用。于是程序员们发明了虚拟内存(Virtual Memory)。

虚拟内存技术支持程序访问比物理内存大得多的内存空间,也使得多个程序共享内存更加高效。物理内存由 RAM 芯片提供,虚拟内存则依靠透明的使用磁盘空间,使程序运行起来好像有了更大的内存空间。

但是问题依然存在,进程和线程的创建都需要消耗一定的内存,每创建一个栈空间,都会产生内存开销,当内存使用超过物理内存的时候,一部分数据就会持久化到磁盘上,随之而来的就是性能的大幅度下降。

这就像银行挤兑,人们把现金存入银行,收取一定的利息,平时只有少数人去银行取现,银行会拿人们存的钱去做更有价值的投资。但是,如果大部分人都去银行取现,银行是没有那么多现金的。取不到钱的用户,被门挡在外面的用户,一定会去拉横幅喊口号「最喜欢双截棍柔中带刚,不喜欢银行就上少林武当」云云,于是银行就处于不可用状态了。现在的 P2P 理财也是一个道理,投资者都去变现,无论是多么良性的资产,一样玩完。

为什么现在会有这么大的连接需求呢?因为业务驱动和技术发展嘛。除了普通的网页浏览和表单提交,即时通信和实时互动交流越来越成为主流需求,keep-alive 技术也能让浏览器产生长连接,实时在线的客户端越来越多,如果不能解决 C10K 问题,将导致服务商需要购买大量的服务器,而每一台服务器都不能做到物尽其用,即使你配置了更好的 CPU 和更大的内存。

当然,现在我们早已经突破了 C10K 这个瓶颈,具体的思路就是通过单个进程或线程服务于多个客户端请求,通过异步编程和事件触发机制替换轮训,IO 采用非阻塞的方式,减少不必要的性能损耗,等等。
底层的相关技术包括 epoll、kqueue、libevent 等,应用层面的解决方案包括 OpenResty、Golang、Node.js 等,比如 OpenResty 的介绍中是这么说的:

OpenResty 通过汇聚各种设计精良的 Nginx 模块,从而将 Nginx 有效地变成一个强大的通用 Web 应用平台。这样,Web 开发人员和系统工程师可以使用 Lua 脚本语言调动 Nginx 支持的各种 C 以及 Lua 模块,快速构造出足以胜任 C10K 乃至 C1000K 以上单机并发连接的高性能 Web 应用系统。

据说现在都去搞 C10M 了,你们怕不怕?
实际操作中,每个解决方案都不是那么容易实现的,很多技术领域油光水滑的东西,放到线上,往往会出现各种各样的问题和毛病。松本行弘先生介绍了一个「最弱连接」的概念:

如果往两端用力拉一条由很多环 (连接)组成的锁链,其中最脆弱的一个连接会先断掉。因此,锁链整体的强度取决于其中最脆弱的一环。

10K 问题的情况也很相似。一台服务器同时应付超过一万个(或者更多)并发连接的情况,哪怕只有一个要素没有考虑到超过一万个客户端的情况,这个要素就会成为「最弱连接」,从而导致问题的发生。
每个做架构设计和技术实现的程序员,都应当考虑这个最弱连接问题。

posted @ 2020-05-25 20:50  字符串爱了数组  阅读(315)  评论(0编辑  收藏  举报