一、套接字Socket
基于 TCP UDP 协议的 Socket 编程,在讲 TCP 和 UDP 协议的时候,我们分客户端和服务端,在写程序的时候,我们也同样这样分。
在网络层,Socket 函数需要指定到底是 IPv4 还是 IPv6,分别对应设置为 AF_INET 和 AF_INET6。另外,还要指定到底是 TCP 还是 UDP。还记得咱们前面讲过的,TCP 协议是基于数据流的,所以设置为 SOCK_STREAM,而 UDP 是基于数据报的,因而设置为 SOCK_DGRAM。
监听的 Socket 和真正用来传数据的 Socket 是两个,一个叫作监听 Socket,一个叫作已连接 Socket
比喻:接待客人
想象你在家里准备接待客人,你有一个门铃和一个客厅。门铃相当于监听 Socket,而客厅相当于已连接 Socket。
-
监听 Socket(门铃):
- 你家的门铃一直在等待有人按下它,这就像服务器上的监听 Socket 一直在等待新的连接请求。
- 当有人按下门铃时,你知道有客人到访了,但你还不知道是谁,也还没有开始与客人交谈。
-
已连接 Socket(客厅):
- 当你开门迎接客人,并带他们到客厅后,你就开始与客人交流了,这时候的客人就相当于已连接 Socket。
- 在客厅里,你可以与每个客人进行独立的对话,不会相互干扰。
技术解释
在网络编程中,特别是 TCP 服务器程序中,监听 Socket 和已连接 Socket 是两个不同的概念和用途:
-
监听 Socket:
- 作用:用来监听和接受新的连接请求。
- 创建:在服务器启动时创建,并绑定到特定的 IP 地址和端口。
- 工作方式:服务器调用
listen()
方法,使这个 Socket 进入监听状态,等待客户端的连接请求。当有客户端请求连接时,服务器调用accept()
方法,从监听 Socket 接受连接请求。
-
已连接 Socket:
- 作用:用来与客户端进行实际的数据传输。
- 创建:当服务器调用
accept()
方法并成功接收一个客户端连接后,会生成一个新的已连接 Socket。这个 Socket 专门用于与该客户端进行通信。 - 工作方式:服务器使用这个已连接 Socket 调用
send()
和recv()
方法,与客户端交换数据。
基于 TCP 协议的 Socket 程序函数调用过程
write() 和 read():适用于所有类型的文件描述符,包括 Socket,功能简单直接。
send() 和 recv():专为网络 Socket 设计,提供额外的功能和灵活性,通过 flags 参数控制行为。
基于 UDP 协议的 Socket 程序函数调用过程
对于 UDP 来讲,过程有些不一样。UDP 是没有连接的,所以不需要三次握手,也就不需要调用 listen 和 connect,但是,UDP 的的交互仍然需要 IP 和端口号,因而也需要 bind。UDP 是没有维护连接状态的,因而不需要每对连接建立一组 Socket,而是只要有一个 Socket,就能够和多个客户端通信。也正是因为没有连接状态,每次通信的时候,都调用 sendto 和 recvfrom,都可以传入 IP 地址和端口。
服务器如何接更多的项目?
会了这几个基本的 Socket 函数之后,你就可以轻松地写一个网络交互的程序了。就像上面的过程一样,在建立连接后,进行一个 while 循环。客户端发了收,服务端收了发。
当然这只是万里长征的第一步,因为如果使用这种方法,基本上只能一对一沟通。如果你是一个服务器,同时只能服务一个客户,肯定是不行的。这就相当于老板成立一个公司,只有自己一个人,自己亲自上来服务客户,只能干完了一家再干下一家,这样赚不来多少钱。
那作为老板你就要想了,我最多能接多少项目呢?当然是越多越好。
我们先来算一下理论值,也就是最大连接数,系统会用一个四元组来标识一个 TCP 连接。
{本机 IP, 本机端口, 对端 IP, 对端端口}
服务器通常固定在某个本地端口上监听,等待客户端的连接请求。因此,服务端端 TCP 连接四元组中只有对端 IP, 也就是客户端的 IP 和对端的端口,也即客户端的端口是可变的,因此,最大 TCP 连接数 = 客户端 IP 数×客户端端口数。对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。
当然,服务端最大并发 TCP 连接数远不能达到理论上限。首先主要是文件描述符限制,按照上面的原理,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;另一个限制是内存,按上面的数据结构,每个 TCP 连接都要占用一定内存,操作系统是有限的。
所以,作为老板,在资源有限的情况下,要想接更多的项目,就需要降低每个项目消耗的资源数目。
方式一:将项目外包给其他公司(多进程方式)
方式二:将项目转包给独立的项目组(多线程方式)
上面这种方式你应该也能发现问题,如果每次接一个项目,都申请一个新公司,然后干完了,就注销掉这个公司,实在是太麻烦了。毕竟一个新公司要有新公司的资产,有新的办公家具,每次都买了再卖,不划算。
于是你应该想到了,我们可以使用线程。相比于进程来讲,这样要轻量级的多。如果创建进程相当于成立新公司,购买新办公家具,而创建线程,就相当于在同一个公司成立项目组。一个项目做完了,那这个项目组就可以解散,组成另外的项目组,办公家具可以共用。
上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程。一台机器无法创建很多进程或者线程。有个C10K,它的意思是一台机器要维护 1 万个连接,就要创建 1 万个进程或者线程,那么操作系统是无法承受的。如果维持 1 亿用户在线需要 10 万台服务器,成本也太高了。
方式三:一个项目组支撑多个项目(IO 多路复用,一个线程维护多个 Socket)
当然,一个项目组可以看多个项目了。这个时候,每个项目组都应该有个项目进度墙,将自己组看的项目列在那里,然后每天通过项目墙看每个项目的进度,一旦某个项目有了进展,就派人去盯一下。
由于 Socket 是文件描述符,因而某个线程盯的所有的 Socket,都放在一个文件描述符集合 fd_set 中,这就是项目进度墙,然后调用 select 函数来监听文件描述符集合是否有变化。一旦有变化,就会依次查看每个文件描述符。那些发生变化的文件描述符在 fd_set 对应的位都设为 1,表示 Socket 可读或者可写,从而可以进行读写操作,然后再调用 select,接着盯着下一轮的变化。。
方式四:一个项目组支撑多个项目(IO 多路复用,从“派人盯着”到“有事通知”)
上面 select 函数还是有问题的,因为每次 Socket 所在的文件描述符集合中有 Socket 发生变化的时候,都需要通过轮询的方式,也就是需要将全部项目都过一遍的方式来查看进度,这大大影响了一个项目组能够支撑的最大的项目数量。因而使用 select,能够同时盯的项目数量由 FD_SETSIZE 限制。
如果改成事件通知的方式,情况就会好很多,项目组不需要通过轮询挨个盯着这些项目,而是当项目进度发生变化的时候,主动通知项目组,然后项目组再根据项目进展情况做相应的操作。
能完成这件事情的函数叫 epoll,它在内核中的实现不是通过轮询的方式,而是通过注册 callback 函数的方式,当某个文件描述符发送变化的时候,就会主动通知。
假设进程打开了 Socket m, n, x 等多个文件描述符,现在需要通过 epoll 来监听是否这些 Socket 都有事件发生。其中 epoll_create 创建一个 epoll 对象,也是一个文件,也对应一个文件描述符,同样也对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树里,要保存这个 epoll 要监听的所有 Socket。
当 epoll_ctl 添加一个 Socket 的时候,其实是加入这个红黑树,同时红黑树里面的节点指向一个结构,将这个结构挂在被监听的 Socket 的事件列表中。当一个 Socket 来了一个事件的时候,可以从这个列表中得到 epoll 对象,并调用 call back 通知它。
这种通知方式使得监听的 Socket 数据增加的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了。上限就为系统定义的、进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器
名词解释
文件描述符限制是指一个操作系统中能够同时打开的文件和网络连接的数量上限。为了理解这个概念,我们可以先了解什么是文件描述符,然后解释为什么它会限制并发 TCP 连接数。
什么是文件描述符?
在操作系统中,每个文件(包括网络连接)在打开时,都会被分配一个唯一的标识符,这个标识符就叫做文件描述符(File Descriptor,简称 FD)。文件描述符是一个非负整数,用来引用一个打开的文件或网络连接。
- 文件:任何类型的文件,比如文本文件、图片文件等。
- 网络连接:TCP 连接、UDP 连接等。
- 其他资源:如管道、设备等。
文件描述符限制
操作系统对每个进程能够同时打开的文件描述符数量有限制,这是出于资源管理和安全的考虑。这个限制通常可以分为两个层次:
- 软限制:用户或进程可以更改的限制,一般默认较小,但可以通过修改系统设置或在程序中动态调整。
- 硬限制:系统级的限制,只有管理员可以更改,通常比软限制要大。
举个例子
假设你在编写一个服务器程序,这个服务器需要处理很多客户端的连接,每个连接对应一个文件描述符。
- 默认限制:操作系统可能默认限制每个进程只能打开 1024 个文件描述符。如果你有超过 1024 个客户端同时连接到服务器,新的连接将无法建立,因为文件描述符已经用完。
- 调整限制:你可以通过修改系统配置来增加文件描述符的限制。例如,在 Linux 系统中,你可以通过修改
/etc/security/limits.conf
文件或使用ulimit
命令来调整这个限制。
为什么文件描述符限制会影响并发连接数?
每个 TCP 连接在服务器端都需要一个文件描述符来表示和管理。如果文件描述符用完了,服务器将无法接受新的连接,即使硬件和其他资源还能够处理更多的连接。这就导致了并发 TCP 连接数远不能达到理论上的上限。
应用层协议
二、HTTP协议:看个新闻原来这么麻烦
HTTP 是基于 TCP 协议的,要先建立 TCP 连接
建立了连接以后,浏览器就要发送 HTTP 的请求。
HTTP 请求的创建
第一部分:请求行
GET POST PUT DELETE
POST 往往是用来创建一个资源的,而 PUT 往往是用来修改一个资源的。
第二部分:首部字段
例如,Accept-Charset,表示客户端可以接受的字符集。防止传过来的是另外的字符集,从而导致出现乱码。
再如,Content-Type是指正文的格式。例如,我们进行 POST 的请求,如果正文是 JSON,那么我们就应该将这个值设置为 JSON。
在 HTTP 协议中,Cache-Control
和 If-Modified-Since
是用于控制缓存行为和条件请求的头字段。让我们通俗易懂地解释它们的作用和工作方式。
Cache-Control 头字段用于指定缓存机制的指令,这些指令告诉浏览器和中间缓存服务器如何缓存 HTTP 响应。它可以帮助提高网站性能和减少带宽消耗。
常见指令
-
public:响应可以被任何缓存(包括浏览器、代理服务器等)缓存。
- 例子:
Cache-Control: public
- 例子:
-
private:响应只能被用户的浏览器缓存,不能被共享缓存(如代理服务器)缓存。
- 例子:
Cache-Control: private
- 例子:
-
no-cache:缓存可以存储响应,但在使用前必须先验证其有效性(向服务器发送请求确认)。
- 例子:
Cache-Control: no-cache
- 例子:
-
no-store:不允许缓存响应,所有内容每次都必须从服务器获取。
- 例子:
Cache-Control: no-store
- 例子:
-
max-age:指定响应在缓存中可以保存的最大时间(以秒为单位),在此时间内缓存内容被认为是新鲜的。
- 例子:
Cache-Control: max-age=3600
(缓存内容在1小时内有效)
- 例子:
场景:用户访问网页
-
第一次访问:
- 用户浏览器向服务器请求网页。
- 服务器返回网页内容,并在响应头中包含
Cache-Control: max-age=3600
和Last-Modified
头字段。 - 浏览器将网页缓存1小时。
-
在1小时内再次访问:
- 浏览器检查缓存,发现缓存仍然有效(未超过
max-age
)。 - 浏览器直接从缓存中加载网页,无需向服务器发送请求。
- 浏览器检查缓存,发现缓存仍然有效(未超过
-
超过1小时再次访问:
- 浏览器向服务器发送请求,包含If-Modified-Since头字段,指示上次接收到的
Last-Modified
时间。 - 服务器检查资源是否自该时间以来有修改:
- 如果没有修改,返回
304 Not Modified
,浏览器使用缓存内容。 - 如果有修改,返回新的网页内容和新的
Last-Modified
时间,浏览器更新缓存。
- 如果没有修改,返回
- 浏览器向服务器发送请求,包含If-Modified-Since头字段,指示上次接收到的
HTTP 请求的发送
就是 TCP 传输
HTTP 2.0
HTTP/2 和 HTTP/1.1 是两个版本的超文本传输协议,它们有许多不同之处,主要目的是提高性能和效率。以下是 HTTP/2 和 HTTP/1.1 的主要区别,通俗易懂地解释这些技术细节:
1. 多路复用
- HTTP/1.1:每个请求-响应对都需要一个独立的 TCP 连接。这意味着如果一个网页上有多个资源(如图片、CSS 文件、JavaScript 文件等),每个资源的请求通常需要单独的连接,导致了“队头阻塞”(Head-of-Line Blocking)问题:一个请求阻塞了,后续请求也无法进行。
- HTTP/2:引入了多路复用技术,多个请求和响应可以在一个单一的 TCP 连接中同时进行。这样可以有效地利用网络资源,减少延迟。
2. 二进制分帧
- HTTP/1.1:使用纯文本格式来传输数据,包括请求和响应头部。这种格式在解析时效率较低。
- HTTP/2:使用二进制分帧层(Binary Framing Layer),将所有传输的信息(头部和数据)编码为二进制格式。这种方式更高效、解析更快,并且更容易实现多路复用。
3. 头部压缩
- HTTP/1.1:每次请求都会携带完整的头部信息,头部信息往往很大且包含重复的内容,浪费了带宽。
- HTTP/2:使用 HPACK 压缩算法对头部信息进行压缩,大大减少了头部的大小和冗余信息,提高了传输效率。
4. 服务器推送
- HTTP/1.1:只有客户端可以主动请求资源,服务器只能被动响应。
- HTTP/2:引入了服务器推送功能,服务器可以在客户端请求某个资源时,主动推送其他相关资源到客户端,这样客户端就不需要再单独请求这些资源了。例如,当客户端请求一个 HTML 页面时,服务器可以提前推送相关的 CSS 和 JavaScript 文件。
5. 流量控制
- HTTP/1.1:没有针对流量控制的机制,所有请求-响应对共享带宽,可能导致性能不稳定。
- HTTP/2:引入了流量控制机制,可以更好地管理和分配带宽,确保各个请求的传输速度和效率。
6. 优先级和依赖关系
- HTTP/1.1:没有内置的请求优先级机制,所有请求的处理顺序主要取决于到达服务器的顺序。
- HTTP/2:允许客户端为每个请求分配优先级,并建立依赖关系,使得重要的资源可以优先传输,优化了页面加载顺序和速度。
QUIC
尽管 HTTP/2 引入了多路复用技术,使得多个流可以在一个 TCP 连接上并行传输,但由于底层使用的是 TCP 协议,TCP 必须保证数据包按顺序和完整性传输。如果某个数据包出现问题,整个连接上的所有数据传输都会被阻塞,直到问题数据包被正确重传和接收。这就意味着,即使在 HTTP/2 中,某个流的数据包出现问题,其他流的数据传输也会受到影响,无法完全避免队头阻塞的问题
于是,就又到了从 TCP 切换到 UDP。这就是 Google 的 QUIC 协议
机制一:自定义连接机制
我们都知道,一条 TCP 连接是由四元组标识的,分别是源 IP、源端口、目的 IP、目的端口。一旦一个元素发生变化时,就需要断开重连,重新连接。在移动互联情况下,当手机信号不稳定或者在 WIFI 和 移动网络切换时,都会导致重连,从而进行再次的三次握手,导致一定的时延。
这在 TCP 是没有办法的,但是基于 UDP,就可以在 QUIC 自己的逻辑里面维护连接的机制,不再以四元组标识,而是以一个 64 位的随机数作为 ID 来标识,而且 UDP 是无连接的,所以当 IP 或者端口变化的时候,只要 ID 不变,就不需要重新建立连接。