🚙

💨 💨 💨

×

  • Categories

  • Archives

  • Tags

  • About

服务器开发自我修养专栏-Linux进程管理

Posted on 03-08-2021 | In Self-cultivation

Linux进程管理

读者-写者问题

定义: 允许多个进程同时对数据进行读操作,但是不允许读和写以及写和写操作同时发生。

解决方案:

  • 读者优先
    读进程只要看到有其他读进程正在访问文件,就可以继续作读访问;写进程必须等待所有读进程都不访问时才能写文件,即使写进程可能比一些读进程更早提出申请。
  • 读者写者公平竞争,老实排队
    因为读者优先的方案如果在读访问非常频繁的场合,有可能造成写进程一直无法访问文件的局面….为了避免这种情况的产生,读者写者请求都老实排队, 排到谁就执行谁, 不准读者插队
  • 写者优先
    如果有写者申请写文件,那么在申请之前已经开始读取文件的可以继续读取,但是如果再有读者申请读取文件,则不能够读取,只有在所有的写者写完之后才可以读取

哲学家就餐问题

5 个沉默寡言的哲学家围坐在圆桌前,每人面前一盘意面。叉子放在哲学家之间的桌面上。(5 个哲学家,5 根叉子)

所有的哲学家都只会在思考和进餐两种行为间交替。哲学家只有同时拿到左边和右边的叉子才能吃到面,而同一根叉子在同一时间只能被一个哲学家使用。每个哲学家吃完面后都需要把叉子放回桌面以供其他哲学家吃面。只要条件允许,哲学家可以拿起左边或者右边的叉子,但在没有同时拿到左右叉子时不能进食。

设计一个进餐规则(并行算法)使得每个哲学家都不会挨饿;也就是说,在没有人知道别人什么时候想吃东西或思考的情况下,每个哲学家都可以在吃饭和思考之间一直交替下去。

显而易见,如果不小心处理会有死锁现象, 比如:当每个科学家都同时拿起了左边的筷子时候死锁发生了,都想拿自己右边的筷子,但是科学家每个人左手都不松手。导致都吃不了饭

参考
解决方案:

  • 规定奇数号科学家先拿左边的筷子,然后拿右边的筷子。偶数号科学家先拿右边的筷子,然后那左边的筷子。导致0,1科学家竞争1号筷子,2,3科学家竞争3号筷子。四号科学家无人竞争。最后总有一个科学家能获得两只筷子。
  • 仅当科学家左右两只筷子都能用的时候,才允许他进餐,代码里的用trylock来实现
  • 至多允许四个哲学家同时去拿左边的筷子,最终保证至少有一个科学家能进餐,并且用完之后释放筷子,从而使更多的哲学家能够拿到筷子。

活锁

在某种情形下,轮询(忙等待)可用于进入临界区或存取资源。采用这一策略的主要原因是,相比所做的工作而言,互斥的时间很短而挂起等待的时间开销很大。考虑一个原语,通过该原语,调用进程测试一个互斥信号量,然后或者得到该信号量或者返回失败信息。

现在假设有一对进程使用两种资源。每个进程需要两种资源,它们利用轮询原语enter_region去尝试取得必要的锁,如果尝试失败,则该进程继续尝试。如果进程A先运行并得到资源1,然后进程2运行并得到资源2,以后不管哪一个进程运行,都不会有任何进展,但是哪一个进程也没有被阻塞。结果是两个进程总是一再消耗完分配给它们的CPU配额,但是没有进展也没有阻塞。因此,没有出现死锁现象(因为没有进程阻塞),但是从现象上看好像死锁发生了,这就是活锁(livelock)。

死锁

参考

必要条件

(口诀互占不还?233):

  • 互斥:每个资源要么已经分配给了一个进程,要么就是可用的。
  • 占有和等待:已经得到了某个资源的进程可以再请求新的资源。
  • 不可抢占:已经分配给一个进程的资源不能强制性地被抢占,它只能被占有它的进程显式地释放。
  • 环路等待:有两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待下一个进程所占有的资源。

死锁处理方法大纲

主要有以下四种方法:

  • 鸵鸟策略
  • 死锁检测与死锁恢复
  • 死锁预防
  • 死锁避免

鸵鸟策略

把头埋在沙子里,假装根本没发生问题。
因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。大多数操作系统,包括 Unix,Linux 和 Windows,处理死锁问题的办法仅仅是忽略它。

死锁检测与死锁恢复

不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。

每种类型一个资源的死锁检测

上图为资源分配图,其中方框表示资源,圆圈表示进程。资源指向进程表示该资源已经分配给该进程,进程指向资源表示进程请求获取该资源。

图 a 可以抽取出环,如图 b,它满足了环路等待条件,因此会发生死锁。

每种类型一个资源的死锁检测算法是通过检测有向图是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有向图存在环,也就是检测到死锁的发生。(当然也可以用拓扑排序思路来检测哈)

每种类型多个资源的死锁检测

上图中,有三个进程四个资源,每个数据代表的含义如下:

  • E 向量:资源总量
  • A 向量:资源剩余量
  • C 矩阵:每个进程所拥有的资源数量,每一行都代表一个进程拥有资源的数量
  • R 矩阵:每个进程请求的资源数量

进程 P1 和 P2 所请求的资源都得不到满足,只有进程 P3 可以,让 P3 执行,之后释放 P3 拥有的资源,此时 A = (2 2 2 0)。P2 可以执行,执行后释放 P2 拥有的资源,A = (4 2 2 1) 。P1 也可以执行。所有进程都可以顺利执行,没有死锁。

算法总结如下:

每个进程最开始时都不被标记,执行过程有可能被标记。当算法结束时,任何没有被标记的进程都是死锁进程。

  1. 寻找一个没有标记的进程 Pi,它所请求的资源小于等于 A。
  2. 如果找到了这样一个进程,那么将 C 矩阵的第 i 行向量加到 A 中,标记该进程,并转回 1。
  3. 如果没有这样一个进程,算法终止。

死锁恢复

  • 利用抢占恢复
  • 利用回滚恢复
  • 通过杀死进程恢复

死锁预防

在程序运行之前预防发生死锁。

  • 破坏互斥条件
    例如假脱机打印机技术允许若干个进程同时输出,唯一真正请求物理打印机的进程是打印机守护进程。
  • 破坏占有和等待条件
    一种实现方式是规定所有进程在开始执行前请求所需要的全部资源。
  • 破坏不可抢占条件
  • 破坏环路等待
    给资源统一编号,进程只能按编号顺序来请求资源。

死锁避免

在程序运行时避免发生死锁。避免死锁的主要算法是基于一个安全状态的概念。在描述算法前,我们先讨论有关安全的概念。

安全状态的检测

图 a 的第二列 Has 表示已拥有的资源数,第三列 Max 表示总共需要的资源数,Free 表示还有可以使用的资源数。从图 a 开始出发,先让 B 拥有所需的所有资源(图 b),运行结束后释放 B,此时 Free 变为 5(图 c);接着以同样的方式运行 C 和 A,使得所有进程都能成功运行,因此可以称图 a 所示的状态时安全的。

安全状态的定义:如果没有死锁发生,并且即使所有进程突然请求对资源的最大需求,也仍然存在某种调度次序能够使得每一个进程运行完毕,则称该状态是安全的。

安全状态的检测与死锁的检测类似,因为安全状态必须要求不能发生死锁。下面的银行家算法与死锁检测算法非常类似,可以结合着做参考对比。

单个资源的银行家算法

Dijkstra(1965)提出了一种能够避免死锁的调度算法,称为银行家算法(banker’s algorithm),
一个小城镇的银行家,他向一群客户分别承诺了一定的贷款额度,算法要做的是判断对请求的满足是否会进入不安全状态,如果是,就拒绝请求;否则予以分配。

客户们各自做自己的生意,在某些时刻需要贷款(相当于请求资源)。在某一时刻,具体情况如图b所示。这个状态是安全的,由于保留着2个单位,银行家能够拖延除了C以外的其他请求。因而可以让C先完成,然后释放C所占的4个单位资源。有了这4个单位资源,银行家就可以给D或B分配所需的贷款单位,以此类推。

考虑假如向B提供了另一个他所请求的贷款单位,如图b所示,那么我们就有如图c所示的状态,该状态是不安全的。如果忽然所有的客户都请求最大的限额,而银行家无法满足其中任何一个的要求,那么就会产生死锁。不安全状态并不一定引起死锁,由于客户不一定需要其最大贷款额度,但银行家不敢抱这种侥幸心理。

银行家算法就是对每一个请求进行检查,检查如果满足这一请求是否会达到安全状态。若是,那么就满足该请求;若否,那么就推迟对这一请求的满足。为了看状态是否安全,银行家看他是否有足够的资源满足某一个客户。如果可以,那么这笔投资认为是能够收回的,并且接着检查最接近最大限额的一个客户,以此类推。如果所有投资最终都被收回,那么该状态是安全的,最初的请求可以批准。

上图 c 为不安全状态,因此算法会拒绝之前的请求,从而避免进入图 c 中的状态。

多个资源的银行家算法

可以把银行家算法进行推广以处理多个资源

上图中有五个进程,四个资源。左边的图表示已经分配的资源,右边的图表示还需要分配的资源。最右边的 E、P 以及 A 分别表示:总资源、已分配资源以及可用资源,注意这三个为向量,而不是具体数值,例如 A=(1020),表示 4 个资源分别还剩下 1/0/2/0。

检查一个状态是否安全的算法如下:

  • 查找右边的矩阵是否存在一行小于等于向量 A。如果不存在这样的行,那么系统将会发生死锁,状态是不安全的。
  • 假若找到这样一行,将该进程标记为终止,并将其已分配资源加到 A 中。
  • 重复以上两步,直到所有进程都标记为终止,则状态时安全的。

如果一个状态不是安全的,需要拒绝进入这个状态。

linux进程调度

参考 https://juejin.im/post/6844903568613310477

在Linux中,线程和进程一视同仁,所以讲到进程调度,也包含了线程调度。

调度分两种:

  • 非抢占式多任务
    除非任务自己结束,否则将会一直执行。
  • 抢占式多任务(Linux用的是这种)
    这种情况下,由调度程序来决定什么时候停止一个进程的运行,这个强制的挂起动作即为抢占。采用抢占式多任务的基础是使用时间片轮转机制来为每个进程分配可以运行的时间单位。

Linux有两种不同的进程优先级范围:

  • 使用nice值:越大的nice值意味着更低的优先级。 (-19 ~ 20之间)
  • 实时优先级:可配置(通过实时调度API),越高意味着进程优先级越高。

任何实时的进程优先级都高于普通的进程,因此上面的两种优先级范围处于互不相交的范畴。

时间片:Linux中并不是以固定的时间值(如10ms)来分配时间片的,而是将处理器的使用比作为“时间片”划分给进程。这样,进程所获得的实际CPU时间就和系统的负载密切相关。

Linux内核有两个调度类:

  • CFS(完全公平调度器Completely Fair Scheduler)
  • 实时调度类。

公平调度CFS

举个例子来区分Unix调度和CFS, 有两个运行的优先级相同的进程:

  • 在Unix中可能是每个各执行5ms,执行期间完全占用处理器,但在“理想情况”下,应该是,能够在10ms内同时运行两个进程,每个占用处理器一半的能力。
  • CFS的做法是:CFS 调度程序并不采用严格规则来为一个优先级分配某个长度的时间片, 在所有可运行进程的总数上计算出一个进程应该运行的时间,nice值不再作为时间片分配的标准,而是用于处理计算获得的处理器使用权重。

现在我们来看一个简单的例子,假设我们的系统只有两个进程在运行,一个是文本编辑器(I/O消耗型),另一个是视频解码器(处理器消耗型)。
理想的情况下,文本编辑器应该得到更多的处理器时间,至少当它需要处理器时,处理器应该立刻被分配给它(这样才能完成用户的交互),这也就意味着当文本编辑器被唤醒的时候,它应该抢占视频解码程序。
按照普通的情况,OS应该分配给文本编辑器更大的优先级和更多的时间片,但在Linux中,这两个进程都是普通进程,他们具有相同的nice值,因此它们将得到相同的处理器使用比(50%)。
但实际的运行过程中会发生什么呢?CFS将能够注意到,文本编辑器使用的处理器时间比分配给它的要少得多(因为大多时间在等待I/O),这种情况下,要实现所有进程“公平”地分享处理器,就会让文本编辑器在需要运行时立刻抢占视频解码器(每次都是如此)。

实时调度

Linux还实现了 POS1X实时调度扩展。这些扩展允许应用程序精确地控制如何分配CPU
给进程。运作在两个实时调度策略

  • SCHED RR (循环)
  • SCHED FIFO (先入先出)

下的进程的优先级总是高于运作在非实时策略下的进程。实时进程优先级的取值范围为1 (低)〜99
(高)。只有进程处于可运行状态,那么优先级更高的进程就会完全将优先级低的进程排除在
CPU之外。运作在SCHED_FIFO策略下的进程会互斥地访问CPU直到它执行终止或自动释放
CPU或被进入可运行状态的优先级更高的进程抢占。类似的规则同样适用于SCHED RR策略,
但在该策略下,如果存在多个进程运行于同样的优先级下,那么CPU就会以循环的方式被这
些进程共享。

实时调度采用 SCHED_FIFO 或 SCHED_RR 实时策略来调度的任何任务,与普通(非实时的)任务相比,具有更高的优先级。

Linux 采用两个单独的优先级范围,一个用于实时任务,另一个用于正常任务。实时任务分配的静态优先级为 0〜99,而正常任务分配的优先级为 100〜139。

这两个值域合并成为一个全局的优先级方案,其中较低数值表明较高的优先级。正常任务,根据它们的nice值,分配一个优先级;这里 -20 的nice值映射到优先级 100,而 +19 的nice值映射到 139。下图显示了这个方案。

linux轻量级进程LWP

对于Linux操作系统而言,它对Thread的实现方式比较特殊。在Linux内核中,其实是没有线程的概念的,它把所有的线程当做标准的进程来实现,也就是说Linux内核,并没有为线程提供任何特殊的调度语义,也没有为线程实现特定的数据结构。取而代之的是,线程只是一个与其他进程共享某些资源的进程。每一个线程拥有一个唯一的task_struct结构,Linux内核它仅仅把线程当做一个正常的进程,或者说是轻量级进程,LWP(Lightweight processes)。

Linux线程与进程的区别,主要体现在资源共享、调度、性能几个方面,首先看一下资源共享方面。上面也提到,线程其实是共享了某一个进程的资源,这些资源包括:

  • 内存地址空间
  • 进程基础信息
  • 大部分数据
  • 打开的文件
  • 信号处理
  • 当前工作目录
  • 用户和用户组属性
  • …

哪些是线程独自拥有的呢?

  • 线程ID
  • 一系列的寄存器
  • 栈的局部变量和返回地址
  • 错误码 errno
  • 信号掩码
  • 优先级
  • …

这里说一个黑科技,线程拥有独立的调用栈,除了栈之外共享了其他所有的段segment。但是由于线程间共享了内存,也就是说一个线程,理论上是可以访问到其他线程的调用栈的,可以用一个指针变量,去访问其他线程的局部栈帧,以访问其他线程的局部变量。

LWP如何创建出来

那么Linux中线程是如何创建出来的呢?上面也提到,在Linux中线程是一种资源共享的方式,可以在创建进程的时候,指定某些资源是从其他进程共享的,从而在概念上创建了一个线程。在Linux中,可以通过clone系统调用来创建一个进程,它的函数签名如下:

#include <sched.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...);

我们在使用clone创建进程的过程中,可以指明相应的参数,来决定共享某些资源,比如:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

这个clone系统调用的行为类似于fork,不过新创建出来的进程,它的内存地址、文件系统资源、打开的文件描述符和信号处理器,都是共享父进程的。换句话说,这个新创建出来的进程,也被叫做Linux Thread。从这个例子中,也可以看出Linux中,线程其实是进程实现资源共享的一种方式。

在内核中,clone调用经过参数传递和解释后会调用do_fork,这个核内函数同时也是fork、vfork系统调用的最终实现:

int do_fork(unsigned long clone_flags, unsigned long stack_start, struct pt_regs* regs,unsigned long stack_size);

在do_fork中,不同的clone_flags将导致不同的行为(共享不同的资源),下面列举几个flag的作用。

  • CLONE_VM
    如果do_fork时指定了CLONE_VM开关,创建的轻量级进程的内存空间将会和父进程指向同一个地址,即创建的轻量级进程将与父进程共享内存地址空间。
  • CLONE_FS
    如果do_fork时指定了CLONE_FS开关,对于轻量级进程则会与父进程共享相同的所在文件系统的根目录和当前目录信息。也就是说,轻量级进程没有独立的文件系统相关的信息,进程中任何一个线程改变当前目录、根目录等信息都将直接影响到其他线程。
  • CLONE_FILES
    如果do_fork时指定了CLONE_FILES开关,创建的轻量级进程与父进程将会共享已经打开的文件。这一共享使得任何线程都能访问进程所维护的打开文件,对它们的操作会直接反映到进程中的其他线程。
  • CLONE_SIGHAND
    如果do_fork时指定了CLONE_FILES开关,轻量级进程与父进程将会共享对信号的处理方式。也就是说,子进程与父进程的信号处理方式完全相同,而且可以相互更改。

尽管linux支持轻量级进程,但并不能说它就支持内核线程,因为linux的”线程”和”进程”实际上处于一个调度层次,共享一个进程标识符空间,这种限制使得不可能在linux上实现完全意义上的POSIX线程机制,因此众多的linux线程库实现尝试都只能尽可能实现POSIX的绝大部分语义,并在功能上尽可能逼近。

多核CPU是否能同时执行多个进程?

多核的作用就是每个CPU可以调度不同的任务“并行”执行。注意,这里说的是“并行”,而不是“并发”,所以问题的回答是“能”。

第二个问题,“同时最多执行几个进程“?
这里你想描述的“同时”的意思,是某一个特定时刻吗?如果是,很明显,在某一特定时刻,每个核只能调度一个任务执行,所以有多少个核最多就可以调度多少个进程(或者说成线程比较准确些)。但在一段时间之内,每个核可以“并发”调度多个任务执行。如何“并发”,这就是由不同操作系统的进程调度策略规定的了,比如常见Linux的CFS调度算法和Windows的抢占式调度算法。

创建守护进程的步骤

(两fork一set, u工文dev)
最关键的三步骤:

  1. 调用fork,然后使父进程exit。
    虽然子进程继承了父进程的进程组ID,但获得了一个新的进程ID,这就保证了子进程不是一个进程组的组长进程。这是下面将要进行的setsid调用的先决条件。

  2. 调用setsid创建一个新会话。
    使调用进程:(a)成为新会话的首进程,(b)成为一个新进程组的组长进程.(c)没有控制终端。也可概括为 : 开启一个新会话并释放它与控制终端之间的所有关联关系

  3. 再次fork并杀掉首进程.
    这样就确保了子进程不是一个会话首进程, 根据linux中获取终端的规则(只有会话首进程才能请求一个控制终端), 这样进程永远不会重新请求一个控制终端

                   会      话
/ | \
/ | \
/ | \
前台进程组 后台进程组1 后台进程组2 ...
/ | \ / | \ / | \
进程1 进程2 ... 进程3 进程4 ... ...

进程组

进程组就是一系列相互关联的进程集合,系统中的每一个进程也必须从属于某一个进程组;每个进程组中都会有一个唯一的 ID(process group id),简称 PGID;PGID 一般等同于进程组的创建进程的 Process ID,而这个进进程一般也会被称为进程组先导 (process group leader)

会话

会话(session)是一个若干进程组的集合,同样的,系统中每一个进程组也都必须从属于某一个会话;一个会话只拥有最多一个控制终端(也可以没有),该终端为会话中所有进程组中的进程所共用。一个会话中前台进程组只会有一个,只有其中的进程才可以和控制终端进行交互;除了前台进程组外的进程组,都是后台进程组;和进程组先导类似,会话中也有会话先导 (session leader) 的概念,用来表示建立起到控制终端连接的进程。在拥有控制终端的会话中,session leader 也被称为控制进程(controlling process),一般来说控制进程也就是登入系统的 shell 进程(login shell);

杀死进程组或会话中的所有进程

我们可以使用该 PGID,通过 kill 命令向整个进程组发送信号:

kill -SIGTERM -- -19701

我们用一个负数 -19701 向进程组发送信号。如果我们传递的是一个正数,这个数将被视为进程 ID 用于终止进程。如果我们传递的是一个负数,它被视为 PGID,用于终止整个进程组。
负数来自系统调用的直接定义。

杀死会话中的所有进程与之完全不同。有些系统没有会话 ID 的概念。即使是具有会话 ID 的系统,例如 Linux,也没有提供系统调用来终止会话中的所有进程。你需要遍历 /proc 输出的进程树,收集所有的 SID,然后一一终止进程。
Pgrep 实现了遍历、收集并通过会话 ID 杀死进程的算法。使用以下命令:

pkill -s <SID>

SIGHUP

SIGHUP 会在以下 3 种情况下被发送给相应的进程:

  • 终端关闭时,该信号被发送到 session 首进程以及作为 job 提交的进程(即用 & 符号提交的进程);
  • session 首进程退出时,该信号被发送到该 session 中的前台进程组中的每一个进程;
  • 若父进程退出导致进程组成为孤儿进程组,且该进程组中有进程处于停止状态(收到 SIGSTOP 或 SIGTSTP 信号),该信号会被发送到该进程组中的每一个进程。

例如:在我们登录 Linux 时,系统会分配给登录用户一个终端 (Session)。在这个终端运行的所有程序,包括前台进程组和后台进程组,一般都属于这个 Session。当用户退出 Linux 登录时,前台进程组和后台有对终端输出的进程将会收到 SIGHUP 信号。这个信号的默认操作为终止进程,因此前台进程组和后台有终端输出的进程就会中止。
此外,对于与终端脱离关系的守护进程,正常情况下是永远都收不到这个信号的, 所以可以人为的发SIGHUP信号给她用于通知它做一些想要的自定义的操作, 比较常见的如重新读取配置文件操作。 比如 xinetd 超级服务程序。

SIGCHLD与僵死进程

SIGCHLD信号,子进程结束时, 父进程会收到这个信号。如果父进程没有处理这个信号,也没有等待(waitpid)子进程,子进程虽然终止,但是还会在内核进程表中占有表项,这时的子进程称为僵尸进程。这种情 况我们应该捕捉它,或者wait它派生的子进程,或者父进程先终止,这时子进程的终止自动由init进程 来接管

SIGPIPE

在网络编程中,SIGPIPE 这个信号是很常见的。当往一个写端关闭的管道或 socket 连接中连续写入数据时会引发 SIGPIPE 信号, 引发 SIGPIPE 信号的写操作将设置 errno 为 EPIPE。在 TCP 通信中,当通信的双方中的一方 close 一个连接时,若另一方接着发数据,根据 TCP 协议的规定,会收到一个 RST 响应报文,若再往这个服务器发送数据时,系统会发出一个 SIGPIPE 信号给进程,告诉进程这个连接已经断开了,不能再写入数据。

因为 SIGPIPE 信号的默认行为是结束进程,而我们绝对不希望因为写操作的错误而导致程序退出,尤其是作为服务器程序来说就更恶劣了。所以我们应该对这种信号加以处理,在这里,介绍处理 SIGPIPE 信号的方式:

一般给 SIGPIPE 设置 SIG_IGN 信号处理函数,忽略该信号:

signal(SIGPIPE, SIG_IGN);

前文说过,引发 SIGPIPE 信号的写操作将设置 errno 为 EPIPE,。所以,第二次往关闭的 socket 中写入数据时, 会返回 - 1, 同时 errno 置为 EPIPE. 这样,便能知道对端已经关闭,然后进行相应处理,而不会导致整个进程退出.

内核态与用户态的区别

  • 内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。
  • 用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。

从用户态到内核态切换可以通过三种方式:

  • 系统调用: 其实系统调用本身就是中断,但是软件中断,跟硬中断不同。
  • 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。例如:缺页异常。
  • 外设中断:当外设完成用户的请求时,会向CPU发送中断信号。

QUIC原理与KCP会话握手借鉴

Posted on 02-26-2021 | In NP

参考:

  • https://zhuanlan.zhihu.com/p/142794794
  • https://zhuanlan.zhihu.com/p/32553477

本文主要介绍 QUIC 协议产生的背景和核心特性。

写在前面

如果你的 App,在不需要任何修改的情况下就能提升 15% 以上的访问速度。特别是弱网络的时候能够提升 20% 以上的访问速度。

如果你的 App,在频繁切换 4G 和 WIFI 网络的情况下,不会断线,不需要重连,用户无任何感知。如果你的 App,既需要 TLS 的安全,也想实现 HTTP2 多路复用的强大。

如果你刚刚才听说 HTTP2 是下一代互联网协议,如果你刚刚才关注到 TLS1.3 是一个革命性具有里程碑意义的协议,但是这两个协议却一直在被另一个更新兴的协议所影响和挑战。

如果这个新兴的协议,它的名字就叫做 “快”,并且正在标准化为新一代的互联网传输协议。

你愿意花一点点时间了解这个协议吗?你愿意投入精力去研究这个协议吗?你愿意全力推动业务来使用这个协议吗?

QUIC 概述

Quic 全称 quick udp internet connection [1],“快速 UDP 互联网连接”,(和英文 quick 谐音,简称 “快”)是由 google 提出的使用 udp 进行多路并发传输的协议。

Quic 相比现在广泛应用的 http2+tcp+tls 协议有如下优势 [2]:

  1. 减少了 TCP 三次握手及 TLS 握手时间。
  2. 改进的拥塞控制。
  3. 避免队头阻塞的多路复用。
  4. 连接迁移。
  5. 前向冗余纠错。

TCP的缺陷

从上个世纪 90 年代互联网开始兴起一直到现在,大部分的互联网流量传输只使用了几个网络协议。使用 IPv4 进行路由,使用 TCP 进行连接层面的流量控制,使用 SSL/TLS 协议实现传输安全,使用 DNS 进行域名解析,使用 HTTP 进行应用数据的传输。

而且近三十年来,这几个协议的发展都非常缓慢。TCP 主要是拥塞控制算法的改进,SSL/TLS 基本上停留在原地,几个小版本的改动主要是密码套件的升级,TLS1.3[3] 是一个飞跃式的变化,但截止到今天,还没有正式发布。IPv4 虽然有一个大的进步,实现了 IPv6,DNS 也增加了一个安全的 DNSSEC,但和 IPv6 一样,部署进度较慢。

随着移动互联网快速发展以及物联网的逐步兴起,网络交互的场景越来越丰富,网络传输的内容也越来越庞大,用户对网络传输效率和 WEB 响应速度的要求也越来越高。

一方面是历史悠久使用广泛的古老协议,另外一方面用户的使用场景对传输性能的要求又越来越高。如下几个由来已久的问题和矛盾就变得越来越突出。

  1. 协议历史悠久导致中间设备僵化。
  2. 依赖于操作系统的实现导致协议本身僵化。
  3. 建立连接的握手延迟大。
  4. 队头阻塞。

这里分小节简单说明一下:

中间设备的僵化

可能是 TCP 协议使用得太久,也非常可靠。所以我们很多中间设备,包括防火墙、NAT 网关,整流器等出现了一些约定俗成的动作。

比如有些防火墙只允许通过 80 和 443,不放通其他端口。NAT 网关在转换网络地址时重写传输层的头部,有可能导致双方无法使用新的传输格式。整流器和中间代理有时候出于安全的需要,会删除一些它们不认识的选项字段。

TCP 协议本来是支持端口、选项及特性的增加和修改。但是由于 TCP 协议和知名端口及选项使用的历史太悠久,中间设备已经依赖于这些潜规则,所以对这些内容的修改很容易遭到中间环节的干扰而失败。

而这些干扰,也导致很多在 TCP 协议上的优化变得小心谨慎,步履维艰。

依赖于操作系统的实现导致协议僵化

TCP 是由操作系统在内核西方栈层面实现的,应用程序只能使用,不能直接修改。虽然应用程序的更新迭代非常快速和简单。但是 TCP 的迭代却非常缓慢,原因就是操作系统升级很麻烦。

现在移动终端更加流行,但是移动端部分用户的操作系统升级依然可能滞后数年时间。PC 端的系统升级滞后得更加严重,windows xp 现在还有大量用户在使用,尽管它已经存在快 20 年。

服务端系统不依赖用户升级,但是由于操作系统升级涉及到底层软件和运行库的更新,所以也比较保守和缓慢。

这也就意味着即使 TCP 有比较好的特性更新,也很难快速推广。比如 TCP Fast Open。它虽然 2013 年就被提出了,但是 Windows 很多系统版本依然不支持它。

建立连接的握手延迟大

不管是 HTTP1.0/1.1 还是 HTTPS,HTTP2,都使用了 TCP 进行传输。HTTPS 和 HTTP2 还需要使用 TLS 协议来进行安全传输。这就出现了两个握手延迟:

1.TCP 三次握手导致的 TCP 连接建立的延迟。

2.TLS 完全握手需要至少 2 个 RTT 才能建立,简化握手需要 1 个 RTT 的握手延迟。

对于很多短连接场景,这样的握手延迟影响很大,且无法消除。

队头阻塞

队头阻塞主要是 TCP 协议的可靠性机制引入的。TCP 使用序列号来标识数据的顺序,数据必须按照顺序处理,如果前面的数据丢失,后面的数据就算到达了也不会通知应用层来处理。

另外 TLS 协议层面也有一个队头阻塞,因为 TLS 协议都是按照 record 来处理数据的,如果一个 record 中丢失了数据,也会导致整个 record 无法正确处理。

概括来讲,TCP 和 TLS1.2 之前的协议存在着结构性的问题,如果继续在现有的 TCP、TLS 协议之上实现一个全新的应用层协议,依赖于操作系统、中间设备还有用户的支持。部署成本非常高,阻力非常大。

所以 QUIC 协议选择了 UDP,因为 UDP 本身没有连接的概念,不需要三次握手,优化了连接建立的握手延迟,同时在应用程序层面实现了 TCP 的可靠性,TLS 的安全性和 HTTP2 的并发性,只需要用户端和服务端的应用程序支持 QUIC 协议,完全避开了操作系统和中间设备的限制。

0RTT建立连接

现如今,高速且安全的网络接入服务已经成为人们的必须。传统 TCP+TLS 构建的安全互联服务,升级与补丁更新时有提出(如 TCP Fastopen,新的 TLS 1.3),但是由于基础设施僵化,升级与应用困难。为解决这个问题,Google 另辟蹊径在 UDP 的基础上实现了带加密的更好的 TCP–QUIC(Quick UDP Internet Connection), 一种基于 UDP 的低时延的互联网传输层协议。近期成立了 Working Group 也将 QUIC 作为制定 HTTP 3.0 的标准的基础, 说明 QUIC 的应用前景美好。本文单独就网络传输的建连问题展开了分析, 浅析了建连时间对传输的影响, 以及 QUIC 的 0-RTT 建连是如何解决建连耗时长的问题的。在此基础上, 结合 QUIC 的源码, 浅析了 QUIC 的基本实现, 并描述一种可供参考的分布式环境下的 0-RTT 的落地实践方案。

以一次简单的 HTTPS 请求(example.com)为例(假设访问 example.com 时返回的内容较小,server 端可以在一个数据包里返回响应),为获取请求资源。需要经过以下 4 个步骤:

  1. DNS 查询 example.com 获取 IP。DNS 服务一般默认是由你的 ISP 提供,ISP 通常都会有缓存的,这部分时间能适当减少;
  2. TCP 握手,我们熟悉的 TCP 三次握手需要需要 1 个 RTT;
  3. TLS 握手,以目前应用最广泛的 TLS 1.2 而言,需要 2 个 RTT。对于非首次建连, 可以选择启用会话重用 (Session Resumption),则可缩小握手时间到 1 个 RTT;
  4. HTTP 业务数据交互,假设 example.com 的数据在一次交互就能取回来。那么业务数据的交互需要 1 个 RTT; 经过上面的过程分析可知,要完成一次简短的 HTTPS 业务数据交互,需要经历:
  • 新连接:4RTT + DNS。
  • 会话重用:3RTT + DNS

尤其对于小数据量的交互而言,抛开 DNS 查询时间, 建连时间占剩下的总时间的 2/3 至 3/4 不等,影响不可小觑。加之如果用户网络不好,RTT 延时大的话,建连时间可能耗费数百毫秒至数秒不等,这将极大的影响用户体验。究其原因一方面是 TCP 和 TLS 分层设计导致的:分层的设计需要每个逻辑层次分别建立自己的连接状态。另一方面是 TLS 的握手阶段复杂的密钥协商机制导致的。要降低建连耗时,需要从这两方面着手。

针对 TLS 的握手阶段复杂的密钥协商机问题, TLS 1.3 精简了握手交互过程, 实现了 1-RTT 握手。在会话重用类似的理念的基础上, 对非首次握手会话, 可以进一步实现 0-RTT 握手 (在刚开始 TLS 密钥协商的时候,就能附送一部分经过加密的数据传递给对方)。由于 TLS 是建立在 TCP 之上的, 0-RTT 没有计算 TCP 层的握手开销, 因而对用户来说, 发送数据之前还是要经历 TCP 层的 1RTT 握手, 因而不是真正的 0-RTT 握手。

TLS 1.3 为实现 0-RTT,需要双方在刚开始建立连接的时候就已经持有一个对称密钥,这个密钥在 TLS 1.3 中称为 PSK(Pre-Shared-Key)。PSK 是 TLS 1.2 中的会话重用 (Session Resumption) 机制的一个升级,TLS 1.3 握手结束后,服务器可以发送一个 NST(New-Session-Ticket)的报文给客户端,该报文中记录 PSK 的值、名字和有效期等信息,双方下一次建立连接可以使用该 PSK 值作为初始密钥材料。因为 PSK 是从以前建立的安全信道中获得的,只要证明了双方都持有相同的 PSK,不再需要证书认证,就可以证明双方的身份(因此 PSK 也是一种身份认证机制)。TLS 1.3 新加了 Early Data 类型的报文, 用于在 0-RTT 的握手阶段传递应用层数据, 实现了握手的同时就能附带加密的应用层数据从而实现 0-RTT。

TLS 1.3 的 0-RTT 特性不能防止重放攻击, 需要业务在使用时评估是否有重放攻击风险。如有相关风险的话, 可能需要酌情考虑禁用 0-RTT 特性。

下图对比了 TLS 各版本与场景下的延时对比:

TLS 各版本与场景下的耗时

从对比我们可以看到, 即使用上了 TLS 1.3, 精简了握手过程, 最快能做到 0-RTT 握手 (首次是 1-RTT), 但是对用户感知而言, 还要加上 1RTT 的 TCP 握手开销。 Google 有提出 Fastopen 的方案来使得 TCP 非首次握手就能附带用户数据, 但是由于 TCP 实现僵化, 无法升级应用, 相关 RFC 到现今都是 experimental 状态。这种分层设计带来的延时, 有没有办法进一步降低呢? QUIC 通过合并加密与连接管理解决了这个问题, 我们来看看其是如何实现真正意义上的 0-RTT 的握手, 让与 server 进行第一个数据包的交互就能带上用户数据。

QUIC 为规避 TCP 协议僵化的问题,将 QUIC 协议建立在了 UDP 之上。考虑到安全性是网络的必备选项,加密在 QUIC 里强制的。传输方面参考 TCP 并充分优化了 TCP 多年发现的缺陷和不足, 实现了一套端到端的可靠加密传输。通过将加密和连接管理两层合二为一,消除了当前 TCP+TLS 的分层设计传输引入的时延。

同 TLS 的握手一样, QUIC 的加密握手的核心在于协商出一个加密会话数据的对称密钥。QUIC 的握手使用了 DH 密钥协商算法来协商一个对称密钥。DH 密钥协商算法简单来讲, 需要通信双方各自生成自己的非对称公私钥对, 双发各自保留自己的私钥, 将公钥发给对方, 利用对方的公钥和自己的私钥可以运算出同一个对称密钥。详细的原理这里不展开叙述, 有专业的密码学书籍对其原理有详细的论述, 网上也有很多好的教程对其由深入浅出的总结, 如这一篇。

如上所述, DH 密钥协商需要通行双方各自生成自己的非对称公私钥对。server 端与客户端的关系是 1 对 N 的关系, 明显 server 端生成一份公私钥对, 让 N 个客户端公用, 能明显减少生成开销, 降低管理的成本。server 端的这份公私钥对就是专门用于握手使用的, 客户端一经获取, 就可以缓存下来后续建连时继续使用, 这个就是达成 0-RTT 握手的关键, 因此 server 生成的这份公钥称为 0-RTT 握手公钥。真正的握手过程是这样 (简化了实现细节):

  1. server 端在握手开始前,server 端需要首先生成 (或加载使用上次保存下来的) 握手公私钥对, 该份公私钥对是所有客户端共享的。
  2. client 端首次握手时, client 对 server 一无所知, 需要 1 个 RTT 来询问 server 端的握手公钥 (实际的握手交互还会发送诸如版本等其他数据) 并缓存下来。本步骤只在首次建连时发生(0-RTT 握手公钥的过期也会导致需要重走这一步), 但这种情况很少发生, 影响很小(也没办法避免)。
  3. client 收到 server 端返回这份握手公钥后,生成自己的临时公私钥对后, 计算出共享的对称密钥后, 加密好数据, 并连同 client 的公钥一并发给 server 端。照 DH 密钥协商的原理, 此处已经可以协商出每条会话不一样的会话密钥了 (因为每个 client 生成的公私钥是不同的), 是不是拿这个来加密会话数据就行了呢? 真实的情况不是这样的!
  4. server 端会再次生成一份临时的公私钥对,使用这份临时的私钥与客户端的公钥运算出最终的会话对称密钥。接下来 server 会拿这个最终的会话密钥加密应用层数据, 连同这份临时的 server 端公钥一并发给 client 端, client 端收到后可以按照 DH 的原理依瓢画葫会恢复出最终的会话对称密钥。后续所有的数据都是用最终的会话对称密钥进行加密。server 侧这个动作是不是多此一举呢? 不是的, 这么做的目的是为了获取所谓的前向安全特性: 因为 server 端的后面生成的这份公私钥是临时生成的, 不会保存下来,也就杜绝了密钥泄漏导致会话数据被恶意收集后的被解密掉的风险。

首次握手要多一个 RTT 询问 server 端的 0-RTT 握手公钥, 此询问过程不携带任何应用层数据, 因此是 1-RTT 握手。在首次握手完成后, client 端可以缓存下第一次询问获知的 server 端的公钥信息, 后续的连接过程可以跳过询问,直接使用缓存的 server 端的公钥。公钥信息在 QUIC 的实现里是保存在握手数据包里的 SCFG 中的 (Server Config), 获取 0-RTT 握手公钥就是要获取 SCFG, 后续均以获取 SCFG 代替。详细的握手过程, 可以参见 dog250 博客文章的详细叙述。

从上面的分析可知,0-RTT 握手的首次交互,server 端使用的是保存下来的握手密钥,因而没有无法做到前向安全,不能防止重放攻击, 需要业务在使用时评估是否有重放攻击风险。

QUIC 0-RTT 握手

上文简述了 QUIC 握手的密钥协商过程,首次需要询问一次 Server 以获取 SCFG, 从而获取存储于其中的 0-RTT 握手公钥。这一交互过程中 client 和 server 在 QUIC 的握手协议发送的报文中分别叫 Inchoate Client hello message (inchoate CHLO) 和 Rejection message (REJ)。因而包含 server 端的握手公钥的 SCFG 是在 REJ 报文中发给客户端的。有了 SCFG,接下来就能发起 0-RTT 握手。这一过程中 client 和 server 端在 QUIC 的握手协议发送的报文中分别叫 Full Client Hello message (full CHLO) 和 Server Hello message (SHLO)。下图摘自 Google QUIC 握手协议的官方文档,详细叙述了握手过程 client 侧的处理流程:

QUIC 握手客户端侧处理流程

0RTT的重放攻击风险

0-RTT连接恢复并非那么简单,它会带来许多意外风险及警告,这正是为什么有些默认程序不启用0-RTT连接恢复的原因。用户必须提前考虑所涉及的风险,并做出决定是否使用此功能。可以在应用程序层面使用预防措施来减轻这种情况。

首先,0-RTT连接恢复是不提供forwardsecrecy的,针对连接的secret parameters的妥协将微不足道地允许恢复新连接0-RTT阶段期间,发送applicationdata 进行特定的妥协。

在0-RTT阶段之后、或握手之后发送的数据,仍然是安全的。这是因为TLS1.3 (及QUIC)还是会对handshakecompletion之后发送的数据执行normal key exchange algorithm (这是forwardsecret) 。

值得注意的是,在0-RTT期间发送的applicationdata 可以被路径上的on-path attacker 捕获,然后多次重播到同一个服务器。这个在大部分情况下也许不是问题,因为attacker无法解密数据,0-RTT连接恢复还是非常有用的。在其他的情况下,这可能会引起出乎意料的风险。

举个比例,假设一家银行允许authenticated user (e.g using HTTPcookie, 或其他HTTP身份验证机制)通过specificAPI endpoint发出HTTP请求将资金从其账户发送到另一个用户。

如果attacker能够在使用0-RTT连接恢复时捕获该请求,他们将无法看到plaintext并且获取用户的凭据,原因是因为他们不知道用于加密数据的secretkey;但是他们仍然有可能一直散发重复性地请求,耗尽该用户的银行账户里的钱。

改进的拥塞控制

TCP 的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复 [22]。

QUIC 协议当前默认使用了 TCP 协议的 Cubic 拥塞控制算法 [6],同时也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等拥塞控制算法。

从拥塞算法本身来看,QUIC 只是按照 TCP 协议重新实现了一遍,那么 QUIC 协议到底改进在哪些方面呢?主要有如下几点:

可插拔

什么叫可插拔呢?就是能够非常灵活地生效,变更和停止。体现在如下方面:

  1. 应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,这在产品快速迭代,网络爆炸式增长的今天,显然有点满足不了需求。
  2. 即使是单个应用程序的不同连接也能支持配置不同的拥塞控制。就算是一台服务器,接入的用户网络环境也千差万别,结合大数据及人工智能处理,我们能为各个用户提供不同的但又更加精准更加有效的拥塞控制。比如 BBR 适合,Cubic 适合。
  3. 应用程序不需要停机和升级就能实现拥塞控制的变更,我们在服务端只需要修改一下配置,reload 一下,完全不需要停止服务就能实现拥塞控制的切换。

STGW 在配置层面进行了优化,我们可以针对不同业务,不同网络制式,甚至不同的 RTT,使用不同的拥塞控制算法。

单调递增的 Packet Number

TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达。

QUIC 同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而 TCP 呢,重传 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不变,也正是由于这个特性,引入了 Tcp 重传的歧义问题。

如上图所示,超时事件 RTO 发生后,客户端发起重传,然后接收到了 Ack 数据。由于序列号一样,这个 Ack 数据到底是原始请求的响应还是重传请求的响应呢?不好判断。

如果算成原始请求的响应,但实际上是重传请求的响应(上图左),会导致采样 RTT 变大。如果算成重传请求的响应,但实际上是原始请求的响应,又很容易导致采样 RTT 过小。

由于 Quic 重传的 Packet 和原始 Packet 的 Pakcet Number 是严格递增的,所以很容易就解决了这个问题。

如上图所示,RTO 发生后,根据重传的 Packet Number 就能确定精确的 RTT 计算。如果 Ack 的 Packet Number 是 N+M,就根据重传请求计算采样 RTT。如果 Ack 的 Pakcet Number 是 N,就根据原始请求的时间计算采样 RTT,没有歧义性。

但是单纯依靠严格递增的 Packet Number 肯定是无法保证数据的顺序性和可靠性。QUIC 又引入了一个 Stream Offset 的概念。

即一个 Stream 可以经过多个 Packet 传输,Packet Number 严格递增,没有依赖。但是 Packet 里的 Payload 如果是 Stream 的话,就需要依靠 Stream 的 Offset 来保证应用数据的顺序。如错误! 未找到引用源。所示,发送端先后发送了 Pakcet N 和 Pakcet N+1,Stream 的 Offset 分别是 x 和 x+y。

假设 Packet N 丢失了,发起重传,重传的 Packet Number 是 N+2,但是它的 Stream 的 Offset 依然是 x,这样就算 Packet N + 2 是后到的,依然可以将 Stream x 和 Stream x+y 按照顺序组织起来,交给应用程序处理。

不允许 Reneging

什么叫 Reneging 呢?就是接收方丢弃已经接收并且上报给 SACK 选项的内容 [8]。TCP 协议不鼓励这种行为,但是协议层面允许这样的行为。主要是考虑到服务器资源有限,比如 Buffer 溢出,内存不够等情况。

Reneging 对数据重传会产生很大的干扰。因为 Sack 都已经表明接收到了,但是接收端事实上丢弃了该数据。

QUIC 在协议层面禁止 Reneging,一个 Packet 只要被 Ack,就认为它一定被正确接收,减少了这种干扰。

更多的 Ack 块

TCP 的 Sack 选项能够告诉发送方已经接收到的连续 Segment 的范围,方便发送方进行选择性重传。

由于 TCP 头部最大只有 60 个字节,标准头部占用了 20 字节,所以 Tcp Option 最大长度只有 40 字节,再加上 Tcp Timestamp option 占用了 10 个字节 [25],所以留给 Sack 选项的只有 30 个字节。

每一个 Sack Block 的长度是 8 个,加上 Sack Option 头部 2 个字节,也就意味着 Tcp Sack Option 最大只能提供 3 个 Block。

但是 Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。

Ack Delay 时间

Tcp 的 Timestamp 选项存在一个问题 [25],它只是回显了发送方的时间戳,但是没有计算接收端接收到 segment 到发送 Ack 该 segment 的时间。这个时间可以简称为 Ack Delay。

这样就会导致 RTT 计算误差。如下图:

  • 可以认为 TCP 的 RTT 计算:RTT = timestamp2 - timestamp1
  • 而 Quic 计算如下:RTT = timestamp2 - timestamp1 - AckDelay

当然 RTT 的具体计算没有这么简单,需要采样,参考历史数值进行平滑计算,参考如下公式

SRTT = SRTT + α(RTT - SRTT)
RTO = μ * SRTT + δ*DevRTT

基于 stream 和 connecton 级别的流量控制

QUIC 的流量控制 [22] 类似 HTTP2,即在 Connection 和 Strea6 级别提供了两种流量控制。为什么需要两类流量控制呢?主要是因为 QUIC 支持多路复用。

  1. Stream 可以认为就是一条 HTTP 请求。
  2. Connection 可以类比一条 TCP 连接。多路复用意味着在一7 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。

QUIC 实现流量控制的原理比较简单:

通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。

通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。

QUIC 的流量控制和 TCP 有点区别,TCP 为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的 Segment,窗口也无法超过这个序列号。

但 QUIC 不同,就算此前有些 packet 没有接收到,它的滑动只取决于接收到的最大偏移字节数。

  • 针对 Stream:可用窗口 = 最大窗口数 - 接收到的最大偏移数
  • 针对 Connection:可用窗口 = stream1可用窗口 + stream2可用窗口 + ... + streamN可用窗口

同样地,STGW 也在连接和 Stream 级别设置了不同的窗口数。

最重要的是,我们可以在内存不足或者上游处理性能出现问题时,通过流量控制来限制传输速率,保障服务可用性。

没有队头阻塞的多路复用

QUIC 的多路复用和 HTTP2 类似。在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (stream)。但是 QUIC 的多路复用相比 HTTP2 有一个很大的优势。

QUIC 一个连接上的多个 stream 之间没有依赖。这样假如 stream2 丢了一个 udp packet,也只会影响 stream2 的处理。不会影响 stream2 之前及之后的 stream 的处理。

这也就在很大程度上缓解甚至消除了队头阻塞的影响。

多路复用是 HTTP2 最强大的特性 [7],能够将多条请求在一条 TCP 连接上同时发出去。但也恶化了 TCP 的一个问题,队头阻塞 [11],如下图示:

HTTP2 在一个 TCP 连接上同时发送 4 个 Stream。其中 Stream1 已经正确到达,并被应用层读取。但是 Stream2 的第三个 tcp segment 丢失了,TCP 为了保证数据的可靠性,需要发送端重传第 3 个 segment 才能通知应用层读取接下去的数据,虽然这个时候 Stream3 和 Stream4 的全部数据已经到达了接收端,但都被阻塞住了。

不仅如此,由于 HTTP2 强制使用 TLS,还存在一个 TLS 协议层面的队头阻塞 [12]。

Record 是 TLS 协议处理的最小单位,最大不能超过 16K,一些服务器比如 Nginx 默认的大小就是 16K。由于一个 record 必须经过数据一致性校验才能进行加解密,所以一个 16K 的 record,就算丢了一个字节,也会导致已经接收到的 15.99K 数据无法处理,因为它不完整。

那 QUIC 多路复用为什么能避免上述问题呢?

  1. QUIC 最基本的传输单元是 Packet,不会超过 MTU 的大小,整个加密和认证过程都是基于 Packet 的,不会跨越多个 Packet。这样就能避免 TLS 协议存在的队头阻塞。
  2. Stream 之间相互独立,比如 Stream2 丢了一个 Pakcet,不会影响 Stream3 和 Stream4。不存在 TCP 队头阻塞。

当然,并不是所有的 QUIC 数据都不会受到队头阻塞的影响,比如 QUIC 当前也是使用 Hpack 压缩算法 [10],由于算法的限制,丢失一个头部数据时,可能遇到队头阻塞。

总体来说,QUIC 在传输大量数据时,比如视频,受到队头阻塞的影响很小。

加密认证的报文

TCP 协议头部没有经过任何加密和认证,所以在传输过程中很容易被中间网络设备篡改,注入和窃听。比如修改序列号、滑动窗口。这些行为有可能是出于性能优化,也有可能是主动攻击。

但是 QUIC 的 packet 可以说是武装到了牙齿。除了个别报文比如 PUBLIC_RESET 和 CHLO,所有报文头部都是经过认证的,报文 Body 都是经过加密的。

这样只要对 QUIC 报文任何修改,接收端都能够及时发现,有效地降低了安全风险。

如下图所示,红色部分是 Stream Frame 的报文头部,有认证。绿色部分是报文内容,全部经过加密。

连接迁移

一条 TCP 连接 [17] 是由四元组标识的(源 IP,源端口,目的 IP,目的端口)。什么叫连接迁移呢?就是当其中任何一个元素发生变化时,这条连接依然维持着,能够保持业务逻辑不中断。当然这里面主要关注的是客户端的变化,因为客户端不可控并且网络环境经常发生变化,而服务端的 IP 和端口一般都是固定的。

比如大家使用手机在 WIFI 和 4G 移动网络切换时,客户端的 IP 肯定会发生变化,需要重新建立和服务端的 TCP 连接。

又比如大家使用公共 NAT 出口时,有些连接竞争时需要重新绑定端口,导致客户端的端口发生变化,同样需要重新建立 TCP 连接。

针对 TCP 的连接变化,MPTCP[5] 其实已经有了解决方案,但是由于 MPTCP 需要操作系统及网络协议栈支持,部署阻力非常大,目前并不适用。

所以从 TCP 连接的角度来讲,这个问题是无解的。

那 QUIC 是如何做到连接迁移呢?很简单,任何一条 QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。

由于这个 ID 是客户端随机产生的,并且长度有 64 位,所以冲突概率非常低。

其他亮点

此外,QUIC 还能实现前向冗余纠错,在重要的包比如握手消息发生丢失时,能够根据冗余信息还原出握手消息。

QUIC 还能实现证书压缩,减少证书传输量,针对包头进行验证等。

限于篇幅,本文不再详细介绍,有兴趣的可以参考文档 [23] 和文档 [4] 和文档 [26]。

服务器开发自我修养专栏-TCP详解

Posted on 02-16-2021 | In Self-cultivation

TCP

包头长度 20个字节

三次握手

如果第三次握手的ack丢失了咋办

当客户端收到服务端的SYNACK应答后,其状态变为ESTABLISHED,并会发送ACK包给服务端,准备发送数据了。如果此时ACK在网络中丢失(如上图所示),过了超时计时器后,那么服务端会重新发送SYNACK包,重传次数根据/proc/sys/net/ipv4/tcp_synack_retries来指定,默认是5次。如果重传指定次数到了后,仍然未收到ACK应答,那么一段时间后,Server自动关闭这个连接。

问题就在这里,客户端已经认为连接建立,而服务端则可能处在SYN-RCVD或者CLOSED,接下来我们需要考虑这两种情况下服务端的应答:

  • 服务端处于CLOSED,当接收到连接已经关闭的请求时,服务端会返回RST 报文,客户端接收到后就会关闭连接,如果需要的话则会重连,那么那就是另一个三次握手了。
  • 服务端处于SYN-RCVD,此时如果接收到正常的ACK 报文,那么很好,连接恢复,继续传输数据;如果接收到写入数据等请求呢?注意了,此时写入数据等请求也是带着ACK 报文的,实际上也能恢复连接,使服务器恢复到ESTABLISHED状态,继续传输数据。

SYN-Flood与SYN-Cookie

所谓SYN-Flood(SYN 洪泛攻击),就是利用SYNACK 报文的时候,服务器会为客户端请求分配缓存,那么黑客(攻击者),就可以使用一批虚假的ip向服务器大量地发建立TCP 连接的请求,服务器为这些虚假ip分配了缓存后,处在SYN_RCVD状态,存放在半连接队列中;另外,服务器发送的请求又不可能得到回复(ip都是假的,能回复就有鬼了),只能不断地重发请求,直到达到设定的时间/次数后,才会关闭。

服务器不断为这些半开连接分配资源,导致服务器的连接资源被消耗殆尽,不过所幸,我们可以使用SYN Cookie进行稍微的防御一下。

所谓的SYN Cookie防御系统,与前面接收到SYN 报文就分配缓存不同,此时暂不分配资源;同时利用SYN 报文的源和目的地IP和端口,以及服务器存储的一个秘密数,使用它们进行散列,得到server_isn作为服务端的初始 TCP 序号,也就是所谓的SYN cookie, 然后将SYNACK 报文中发送给客户端,接下来就是对ACK 报文进行判断,如果其返回的ack里的确认号正好等于server_isn + 1,说明这是一个合法的ACK,那么服务器才会为其生成一个具有套接字的全开的连接。(有点类似于JWT那一套机制哈)

缺点:

  • 增加了密码学运算, 增大了cpu消耗
  • 因为没有保存半连接状态, 所以无法存储一些比如大窗口/sack等信息

四次挥手

timewait的意义

  • 2msl之后网络中的数据分节全部消失, 防止影响到复用了原端口ip的新连接
  • 如果b没收到最后一个ack, b就会重发fin, a如果不维护一个timewait却收到了一个fin会感觉莫名其妙然后响应一个rst, 然后b就会解释为一个错误

timewait和closewait太多咋办

  • timewait太多咋办?
    • net.ipv4.tcp_tw_reuse = 1 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
    • net.ipv4.tcp_tw_recycle = 1 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
    • net.ipv4.tcp_fin_timeout这个时间可以减少在异常情况下服务器从FIN-WAIT-2转到TIME_WAIT的时间。
  • closewait太多咋办?
    • 解决方案只有: 查代码. 因为如果一直保持在CLOSE_WAIT状态,那么只有一种情况,就是在对方关闭连接之后服务器程序自己没有进一步发出fin信号。换句话说,就是在对方连接关闭之后,程序里没有检测到,或者由于什么逻辑bug导致服务端没有主动发起close, 或者程序压根就忘记了这个时候需要关闭连接,于是这个资源就一直被程序占着。

tcp拥塞控制

  • 快速重传:
    报文段1成功接收并被确认ACK 2,接收端的期待序号为2,当报文段2丢失,报文段3失序到来,与接收端的期望不匹配,接收端重复发送冗余ACK 2。这样,如果在超时重传定时器溢出之前,接收到连续的三个重复冗余ACK(其实是收到4个同样的ACK,第一个是正常的,后三个才是冗余的),发送端便知晓哪个报文段在传输过程中丢失了,于是重发该报文段,不需要等待超时重传定时器溢出,大大提高了效率。这便是快速重传机制。
  • 快速恢复
  • 慢启动
  • 拥塞避免

tcp滑动窗口

每个TCP连接的两端都维护一组窗口:发送窗口结构(send window structure)和接收窗口结构(receive window structure)。TCP以字节为单位维护其窗口结构。TCP头部中的窗口大小字段相对ACK号有一个字节的偏移量。发送端计算其可用窗口,即它可以立即发送的数据量。可用窗口(允许发送但还未发送)计算值为提供窗口(即由接收端通告的窗口)大小减去在传(已发送但未得到确认)的数据量。图中P1、P2、P3分别记录了窗口的左边界、下次发送的序列号、右边界。

如上图所示, 随着发送端接收到返回的数据ACK,滑动窗口也随之右移。发送端根据接收端返回的ACK可以得到两个重要的信息:一是接收端期望收到的下一个字节序号;二是当前的窗口大小(再结合发送端已有的其他信息可以得出还能发送多少字节数据)。

需要注意的是:发送窗口的左边界只能右移,因为它控制的是已发送并受到确认的数据,具有累积性,不能返回;右边界可以右移也可以左移(能左移的右边界会带来一些缺陷,下文会讲到)。

接收端也维护一个窗口结构,但比发送窗口简单(只有左边界和右边界)。该窗口结构记录了已接收并确认的数据,以及它能够接收的最大序列号,该窗口能保证接收数据的正确性(避免存储重复的已接收和确认的数据,以及避免存储不应接收的数据)。由于TCP的累积ACK特性,只有当到达数据序列号等于左边界时,窗口才能向前滑动。

零窗口与TCP持续计时器

Zero Window

上图,我们可以看到一个处理缓慢的Server(接收端)是怎么把Client(发送端)的TCP Sliding Window给降成0的。此时,你一定会问,如果Window变成0了,TCP会怎么样?是不是发送端就不发数据了?是的,发送端就不发数据了,你可以想像成“Window Closed”,那你一定还会问,如果发送端不发数据了,接收方一会儿Window size 可用了,怎么通知发送端呢?

解决这个问题,TCP使用了Zero Window Probe技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。

ACK延迟确认机制

接收方在收到数据后,并不会立即回复ACK,而是延迟一定时间。一般ACK延迟发送的时间为200ms,但这个200ms并非收到数据后需要延迟的时间。系统有一个固定的定时器每隔200ms会来检查是否需要发送ACK包。这样做有两个目的。

  • 这样做的目的是ACK是可以合并的,也就是指如果连续收到两个TCP包,并不一定需要ACK两次,只要回复最终的ACK就可以了,可以降低网络流量。
  • 如果接收方有数据要发送,那么就会在发送数据的TCP数据包里,带上ACK信息。这样做,可以避免大量的ACK以一个单独的TCP包发送,减少了网络流量。

服务器开发自我修养专栏-Linux高低精度定时器实现原理

Posted on 02-06-2021 | In Self-cultivation

Linux定时器实现原理

时间轮定时器-低分辨率实现

Linux 2.6.16之前,内核只支持低精度时钟,内核定时器的工作方式:

  1. 系统启动后,会读取时钟源设备(RTC,HPET,PIT…),初始化当前系统时间。
  2. 内核会根据HZ(系统定时器频率,节拍率)参数值,设置时钟事件设备,启动tick(节拍)中断。HZ表示1秒种产生多少个时钟硬件中断,tick就表示连续两个中断的间隔时间。
  3. 设置时钟事件设备后,时钟事件设备会定时产生一个tick中断,触发时钟中断处理函数,更新系统时钟,并检测timer wheel,进行超时事件的处理。

在上面工作方式下,Linux 2.6.16 之前,内核软件定时器采用timer wheel多级时间轮的实现机制,维护操作系统的所有定时事件。timer wheel的触发是基于系统tick周期性中断。所以说这之前,linux只能支持ms级别的时钟,随着时钟源硬件设备的精度提高和软件高精度计时的需求,有了高精度时钟的内核设计。

所谓低分辨率定时器,是指这种定时器的计时单位基于jiffies值的计数,也就是说,它的精度只有1HZ,假如你的内核配置的HZ是1000,那意味着系统中的低分辨率定时器的精度就是1ms。早期的内核版本中,内核并不支持高精度定时器,理所当然只能使用这种低分辨率定时器, 后来随着时钟源硬件设备的精度提高和软件高精度计时的需求,才有了高精度时钟的内核设计

. . .

服务器开发自我修养专栏-MySQL原理

Posted on 01-24-2021 | In Self-cultivation

MySQL

参考网址

  • https://chenjiabing666.github.io/2020/04/20/Mysql%E6%9C%80%E5%85%A8%E9%9D%A2%E8%AF%95%E6%8C%87%E5%8D%97/
  • https://blog.csdn.net/qq_41011723/article/details/105953813
  • https://blog.csdn.net/qq_41011723/article/details/106028153
  • MySQL、MongoDB、Redis 数据库之间的区别

mysql一条语句的执行过程

速记: 连/分/优/执/存

char和varchar的区别是什么

  • char(n) :固定长度类型,比如订阅 char(10),当你输入”abc”三个字符的时候,它们占的空间还是 10 个字节,其他 7 个是空字节。
    • chat 优点:效率高;缺点:占用空间;适用场景:存储密码的 md5 值,固定长度的,使用 char 非常合适。
  • varchar(n) :可变长度,存储的值是每个值占用的字节再加上一个用来记录其长度的字节的长度。

所以,从空间上考虑 varcahr 比较合适;从效率上考虑 char 比较合适,二者使用需要权衡。

redo log与binlog与undo log的区别

参考 https://www.cnblogs.com/Java3y/p/12453755.html , 写的非常好
也可参考 https://www.jianshu.com/p/68d5557c65be

redo log

redo log 存在于InnoDB 引擎中,InnoDB引擎是以插件形式引入Mysql的,redo log的引入主要是为了实现Mysql的crash-safe能力。
实际上Mysql的基本存储结构是页(记录都存在页里边),所以MySQL是先把这条记录所在的页找到,然后把该页加载到内存中,将对应记录进行修改。
现在就可能存在一个问题:如果在内存中把数据改了,还没来得及落磁盘,而此时的数据库挂了怎么办?显然这次更改就丢了。

如果每个请求都需要将数据立马落磁盘之后,那速度会很慢,MySQL可能也顶不住。所以MySQL是怎么做的呢?
MySQL引入了redo log,内存写完了,然后会写一份redo log,这份redo log记载着这次在某个页上做了什么修改.其实写redo log的时候,也会有buffer,是先写buffer,再真正落到磁盘中的。至于从buffer什么时候落磁盘,会有配置供我们配置。

写redo log也是需要写磁盘的,但它的好处就是顺序IO(我们都知道顺序IO比随机IO快非常多)。

所以,redo log的存在为了:当我们修改的时候,写完内存了,但数据还没真正写到磁盘的时候。此时我们的数据库挂了,我们可以根据redo log来对数据进行恢复。因为redo log是顺序IO,所以写入的速度很快,并且redo log记载的是物理变化(xxxx页做了xxx修改),文件的体积很小,恢复速度很快

binlog

binlog记录了数据库表结构和表数据变更,比如update/delete/insert/truncate/create。它不会记录select(因为这没有对表没有进行变更)
binlog我们可以简单理解为:存储着每条变更的SQL语句

undo log

undo log主要有两个作用:

  • 回滚
  • 多版本控制(MVCC)

在数据修改的时候,不仅记录了redo log,还记录undo log,如果因为某些原因导致事务失败或回滚了,可以用undo log进行回滚
undo log主要存储的也是逻辑日志,比如我们要insert一条数据了,那undo log会记录的一条对应的delete日志。我们要update一条记录时,它会记录一条对应相反的update记录。

这也应该容易理解,毕竟回滚嘛,跟需要修改的操作相反就好,这样就能达到回滚的目的。因为支持回滚操作,所以我们就能保证:“一个事务包含多个操作,这些操作要么全部执行,要么全都不执行”。【原子性】

因为undo log存储着修改之前的数据,相当于一个前版本,MVCC实现的是读写不阻塞,读的时候只要返回前一个版本的数据就行了。

undolog和binlog和redolog不同之处总结

  • 参考 https://www.jianshu.com/p/68d5557c65be
  • redo log: 只存在于innodb引擎中
    物理格式的日志,记录的是物理数据页面的修改的信息(数据库中每个页的修改),面向的是表空间、数据文件、数据页、偏移量等。
  • undo log
    逻辑格式的日志,在执行undo的时候,仅仅是将数据从逻辑上恢复至事务之前的状态,而不是从物理页面上操作实现的,与redo log不同。
  • binlog
    • 逻辑格式的日志,可以简单认为就是执行过的事务中的sql语句。
    • 但又不完全是sql语句这么简单,而是包括了执行的sql语句(增删改)反向的信息。比如delete操作的话,就对应着delete本身和其反向的insert/update操作的话,就对应着update执行前后的版本的信息;insert操作则对应着delete和insert本身的信息。
    • 因此可以基于binlog做到闪回功能。
  • binlog可以作为恢复数据使用,主从复制搭建,redo log作为异常宕机或者介质故障后的数据恢复使用。
  • redo log是在InnoDB存储引擎层产生,而binlog是MySQL数据库的上层产生的,并且binlog日志不仅仅针对INNODB存储引擎,MySQL数据库中的任何存储引擎对于数据库的更改都会产生binlog日志。
  • 两种日志记录的内容形式不同。MySQL的binlog是逻辑日志,可以简单认为记录的就是sql语句。而innodb存储引擎层面的redo日志是物理日志, 是数据页面的修改之后的物理记录。
  • 关于事务提交时,redo log和binlog的写入顺序,为了保证主从复制时候的主从一致(当然也包括使用binlog进行基于时间点还原的情况),是要严格一致的,MySQL通过两阶段提交过程来完成事务的一致性的,也即redo log和binlog的一致性的,理论上是先写redo log,再写binlog,两个日志都提交成功(刷入磁盘),事务才算真正的完成。因此redo日志的写盘,并不一定是随着事务的提交才写入redo日志文件的,而是随着事务的开始,逐步开始的。那么当我执行一条 update 语句时,redo log 和 binlog 是在什么时候被写入的呢?这就有了我们常说的「两阶段提交」:
    • 写入:redo log(prepare)
    • 写入:binlog
    • 写入:redo log(commit)
  • 两种日志与记录写入磁盘的时间点不同,binlog日志只在事务提交完成后进行一次写入。而innodb存储引擎的redo日志在事务进行中不断地被写入,并日志不是随事务提交的顺序进行写入的。
  • binlog日志仅在事务提交时记录,并且对于每一个事务,仅在事务提交时记录,并且对于每一个事务,仅包含对应事务的一个日志。而对于innodb存储引擎的redo日志,由于其记录是物理操作日志,因此每个事务对应多个日志条目,并且事务的redo日志写入是并发的,并非在事务提交时写入,其在文件中记录的顺序并非是事务开始的顺序。
  • binlog不是循环使用,在写满或者重启之后,会生成新的binlog文件,redo log是循环使用。
  • binlog 日志是 master 推的还是 salve 来拉的?slave来拉的, 因为每一个slave都是完全独立的个体,所以slave完全依据自己的节奏去处理同步,

二阶段提交

redo log 保证的是数据库的 crash-safe 能力。采用的策略就是常说的“两阶段提交”。

一条update的SQL语句是按照这样的流程来执行的:
将数据页加载到内存 → 修改数据 → 更新数据 → 写redo log(状态为prepare) → 写binlog → 提交事务(数据写入成功后将redo log状态改为commit)

只有当两个日志都提交成功(刷入磁盘),事务才算真正的完成。一旦发生系统故障(不管是宕机、断电、重启等等),都可以配套使用 redo log 与 binlog 做数据修复。

两阶段提交机制的必要性

  • binlog 存在于Mysql Server层中,主要用于数据恢复;当数据被误删时,可以通过上一次的全量备份数据加上某段时间的binlog将数据恢复到指定的某个时间点的数据。
  • redo log 存在于InnoDB 引擎中,InnoDB引擎是以插件形式引入Mysql的,redo log的引入主要是为了实现Mysql的crash-safe能力。

假设redo log和binlog分别提交,可能会造成用日志恢复出来的数据和原来数据不一致的情况。

  • 假设先写redo log再写binlog,即redo log没有prepare阶段,写完直接置为commit状态,然后再写binlog。那么如果写完redo log后Mysql宕机了,重启后系统自动用redo log 恢复出来的数据就会比binlog记录的数据多出一些数据,这就会造成磁盘上数据库数据页和binlog的不一致,下次需要用到binlog恢复误删的数据时,就会发现恢复后的数据和原来的数据不一致。
  • 假设先写binlog再写redolog。如果写完binlog后Mysql宕机了,那么binlog上的记录就会比磁盘上数据页的记录多出一些数据出来,下次用binlog恢复数据,就会发现恢复后的数据和原来的数据不一致。

由此可见,redo log和binlog的两阶段提交是非常必要的。

索引

  • 聚集索引(也叫聚簇索引)是啥
    • 聚簇索引:将数据存储与索引放到了一块,找到索引也就找到了数据
    • 非聚簇索引:将数据存储于索引分开结构,索引结构的叶子节点指向了数据的对应行,myisam通过key_buffer把索引先缓存到内存中,当需要访问数据时(通过索引访问数据),在内存中直接搜索索引,然后通过索引找到磁盘相应数据,这也就是为什么索引不在key buffer命中时,速度慢的原因。
  • 外键是啥: 比如在students表中,通过class_id的字段,可以把数据与另一张表关联起来,这种列称为外键(一般不用外键, 因为会降低数据库性能)
  • mysql 索引在什么情况下会失效
    • https://database.51cto.com/art/201912/607742.htm
    • 查询条件包含or,可能导致索引失效
    • 如何字段类型是字符串,where时一定用引号括起来,否则索引失效
    • like通配符可能导致索引失效。
    • 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效。
    • 在索引列上使用mysql的内置函数,索引失效
    • 对索引列运算(如,+、-、*、/),索引失效。
    • 索引字段上使用(!= 或者 < >,not in)时,可能会导致索引失效。
    • 索引字段上使用is null, is not null,可能导致索引失效。
    • 左连接查询或者右连接查询查询关联的字段编码格式不一样,可能导致索引失效。
    • mysql估计使用全表扫描要比使用索引快,则不使用索引。
  • mysql 的索引模型:
    在MySQL中使用较多的索引有Hash索引,B+树索引等,而我们经常使用的InnoDB存储引擎的默认索引实现为:B+树索引。对于哈希索引来说,底层的数据结构就是哈希表,因此在绝大多数需求为单条记录查询的时候,可以选择哈希索引,查询性能最快;其余大部分场景,建议选择BTree索引。

为什么说B类树更适合数据库索引

为什么说B类树更适合数据库索引

mysql全文索引 pending_fin

mysql 有那些存储引擎,有哪些区别

  • innodb是 MySQL 默认的事务型存储引擎,只有在需要它不支持的特性时,才考虑使用其它存储引擎。实现了四个标准的隔离级别,默认级别是可重复读(REPEATABLE READ)。在可重复读隔离级别下,通过多版本并发控制(MVCC)+ Next-Key Locking 防止幻影读。主索引是聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对查询性能有很大的提升。
  • MyISAM类型不支持事务处理等高级处理,而InnoDB类型支持。
  • MyISAM类型的表强调的是性能,其执行速度比InnoDB类型更快,但是不提供事务支持,而InnoDB提供事务支持以及外部键等高级数据库功能。
  • 现在一般都是选用InnoDB了,InnoDB支持行锁, 而MyISAM的全表锁,myisam的读写串行问题,并发效率锁表,效率低,MyISAM对于读写密集型应用一般是不会去选用的
  • memory引擎一般用于临时表, 使用表级锁,没有事务机制, 虽然内存访问快,但如果频繁的读写,表级锁会成为瓶颈, 且内存昂贵..满了就亏了
  • InnoDB是聚集索引,使用B+Tree作为索引结构,数据文件是和(主键)索引绑在一起的(表数据文件本身就是按B+Tree组织的一个索引结构),必须要有主键,通过主键索引效率很高。MyISAM是非聚集索引,也是使用B+Tree作为索引结构,索引和数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的。

综上所述, 如果表的读操作远远多于写操作时,并且不需要事务的支持的,可以将 MyIASM 作为数据库引擎的首选

mysql 主从同步分哪几个过程


复制的基本过程如下:

  1. 从节点上的I/O 线程连接主节点,并请求从指定日志文件的指定位置(或者从最开始的日志)之后的日志内容;
  2. 主节点接收到来自从节点的I/O请求后,通过负责复制的I/O线程根据请求信息读取指定日志指定位置之后的日志信息,返回给从节点。返回信息中除了日志所包含的信息之外,还包括本次返回的信息的bin-log file 的以及bin-log position;从节点的I/O线程接收到内容后,将接收到的日志内容更新到本机的relay log中,并将读取到的binary log文件名和位置保存到master-info 文件中,以便在下一次读取的时候能够清楚的告诉Master“我需要从某个bin-log 的哪个位置开始往后的日志内容,请发给我”;
  3. Slave 的 SQL线程检测到relay-log 中新增加了内容后,会将relay-log的内容解析成在主节点上实际执行过的操作,并在本数据库中执行。

主从同步延迟与同步数据丢失问题

主库将变更写binlog日志,然后从库连接到主库之后,从库有一个IO线程,将主库的binlog日志拷贝到自己本地,写入一个中继日志中。接着从库中有一个SQL线程会从中继日志读取binlog,然后执行binlog日志中的内容,也就是在自己本地再次执行一遍SQL,这样就可以保证自己跟主库的数据是一样的。

这里有一个非常重要的一点,就是从库同步主库数据的过程是串行化的,也就是说主库上并行的操作,在从库上会串行执行。所以这就是一个非常重要的点了,由于从库从主库拷贝日志以及串行执行SQL的特点,在高并发场景下,从库的数据一定会比主库慢一些,是有延时的。所以经常出现,刚写入主库的数据可能是读不到的,要过几十毫秒,甚至几百毫秒才能读取到。

而且这里还有另外一个问题,就是如果主库突然宕机,然后恰好数据还没同步到从库,那么有些数据可能在从库上是没有的,有些数据可能就丢失了。
所以mysql实际上在这一块有两个机制:

  • 一个是半同步复制,用来解决主库数据丢失问题
  • 一个是并行复制,用来解决主从同步延时问题(实在解决不了只能强制读主库)。

半同步复制(Semisynchronous replication)

  • 逻辑上: 是介于全同步复制与全异步复制之间的一种,主库只需要等待至少一个从库节点收到并且 Flush Binlog 到 Relay Log 文件即可,主库不需要等待所有从库给主库反馈。同时,这里只是一个收到的反馈,而不是已经完全完成并且提交的反馈,如此,节省了很多时间。
  • 技术上: 介于异步复制和全同步复制之间,主库在执行完客户端提交的事务后不是立刻返回给客户端,而是等待至少一个从库接收到并写到relay log中才返回给客户端。相对于异步复制,半同步复制提高了数据的安全性,同时它也造成了一定程度的延迟,这个延迟最少是一个TCP/IP往返的时间。所以,半同步复制最好在低延时的网络中使用。

库并行复制

所谓并行复制,指的是从库开启多个线程,并行读取relay log中不同库的日志,然后并行重放不同库的日志,这是库级别的并行。

异步复制(Asynchronous replication)

  • 逻辑上: MySQL默认的复制即是异步的,主库在执行完客户端提交的事务后会立即将结果返给给客户端,并不关心从库是否已经接收并处理,这样就会有一个问题,主如果crash掉了,此时主上已经提交的事务可能并没有传到从库上,如果此时,强行将从提升为主,可能导致新主上的数据不完整。
  • 技术上: 主库将事务 Binlog 事件写入到 Binlog 文件中,此时主库只会通知一下 Dump 线程发送这些新的 Binlog,然后主库就会继续处理提交操作,而此时不会保证这些 Binlog 传到任何一个从库节点上。

全同步复制(Fully synchronous replication)

  • 逻辑上: 指当主库执行完一个事务,所有的从库都执行了该事务才返回给客户端。因为需要等待所有从库执行完该事务才能返回,所以全同步复制的性能必然会收到严重的影响。
  • 技术上: 当主库提交事务之后,所有的从库节点必须收到、APPLY并且提交这些事务,然后主库线程才能继续做后续操作。但缺点是,主库完成一个事务的时间会被拉长,性能降低。

乐观锁与悲观锁的区别?

  • 悲观锁:认为数据随时会被修改,因此每次读取数据之前都会上锁,防止其它事务读取或修改数据;应用于数据更新比较频繁的场景;
  • 乐观锁:操作数据时不会上锁,但是更新时会判断在此期间有没有别的事务更新这个数据,若被更新过,则失败重试;适用于读多写少的场景。

乐观锁怎么实现:

  • 加版本号
  • cas

实现事务采取了哪些技术以及思想?

  • ★ a原子性:使用 undo log ,从而达到回滚
  • ★ d持久性:使用 redo log,从而达到故障后恢复
  • ★ i隔离性:使用锁以及MVCC,运用的优化思想有读写分离,读读并行,读写并行
  • ★ c一致性:通过回滚,以及恢复,和在并发环境下的隔离做到一致性。

mysql四个事务隔离级别

四个隔离级别的区别以及每个级别可能产生的问题以及实现原理
MySQL 的事务隔离是在 MySQL. ini 配置文件里添加的,在文件的最后添加:transaction-isolation = REPEATABLE-READ
可用的配置值:READ-UNCOMMITTED、READ-COMMITTED、REPEATABLE-READ、SERIALIZABLE。

MySQL的事务隔离级别一共有四个,分别是

  • 读未提交
  • 读已提交
  • 可重复读
  • 可串行化

MySQL的隔离级别的作用就是让事务之间互相隔离,互不影响,这样可以保证事务的一致性。

  • 隔离级别比较:可串行化>可重复读>读已提交>读未提交
  • 隔离级别对性能的影响比较:可串行化>可重复读>读已提交>读未提交

由此看出,隔离级别越高,所需要消耗的MySQL性能越大(如事务并发严重性),为了平衡二者,一般建议设置的隔离级别为可重复读,MySQL默认的隔离级别也是可重复读。

事务并发可能出现的情况

  • 脏读(Dirty Read)
    • 一个事务读到了另一个未提交事务修改过的数据
    • 会话B开启一个事务,把id=1的name为武汉市修改成温州市,此时另外一个会话A也开启一个事务,读取id=1的name,此时的查询结果为温州市,会话B的事务最后回滚了刚才修改的记录,这样会话A读到的数据是不存在的,这个现象就是脏读。(脏读只在读未提交隔离级别才会出现)
  • 不可重复读(Non-Repeatable Read)
    • 一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值。(不可重复读在读未提交和读已提交隔离级别都可能会出现)
    • 会话A开启一个事务,查询id=1的结果,此时查询的结果name为武汉市。接着会话B把id=1的name修改为温州市(隐式事务,因为此时的autocommit为1,每条SQL语句执行完自动提交),此时会话A的事务再一次查询id=1的结果,读取的结果name为温州市。会话B再此修改id=1的name为杭州市,会话A的事务再次查询id=1,结果name的值为杭州市,这种现象就是不可重复读。
  • 幻读(Phantom)
    • 一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来。(幻读在读未提交、读已提交、可重复读隔离级别都可能会出现)
    • 会话A开启一个事务,查询id>0的记录,此时会查到name=武汉市的记录。接着会话B插入一条name=温州市的数据(隐式事务,因为此时的autocommit为1,每条SQL语句执行完自动提交),这时会话A的事务再以刚才的查询条件(id>0)再一次查询,此时会出现两条记录(name为武汉市和温州市的记录),这种现象就是幻读。

各个隔离级别的详细说明

  • 读未提交(READ UNCOMMITTED)
    • 在读未提交隔离级别下,事务A可以读取到事务B修改过但未提交的数据。
    • 可能发生脏读、不可重复读和幻读问题,一般很少使用此隔离级别。
  • 读已提交(READ COMMITTED)
    • 在读已提交隔离级别下,事务B只能在事务A修改过并且已提交后才能读取到事务B修改的数据。
    • 读已提交隔离级别解决了脏读的问题,但可能发生不可重复读和幻读问题,一般很少使用此隔离级别。
  • 可重复读(REPEATABLE READ)
    • 在可重复读隔离级别下,事务B只能在事务A修改过数据并提交后,自己也提交事务后,才能读取到事务B修改的数据。
    • 可重复读隔离级别解决了脏读和不可重复读的问题,但可能发生幻读问题。
    • 提问:为什么上了写锁(写操作),别的事务还可以读操作?因为InnoDB有MVCC机制(多版本并发控制),可以使用快照读,而不会被阻塞。
  • 可串行化(SERIALIZABLE)
    • 各种问题(脏读、不可重复读、幻读)都不会发生,通过加锁实现(读锁和写锁)。

mvcc是啥

mvcc必看文章:

  • mysql mvcc实现原理
  • https://chenjiayang.me/2019/06/22/mysql-innodb-mvcc/

MVCC (Multiversion Concurrency Control) 中文全程叫多版本并发控制,是现代数据库(包括 MySQL、Oracle、PostgreSQL 等)引擎实现中常用的处理读写冲突的手段,目的在于提高数据库高并发场景下的吞吐性能。MVCC 的每一个写操作都会创建一个新版本的数据,读操作会从有限多个版本的数据中挑选一个最合适(要么是最新版本,要么是指定版本)的结果直接返回 。通过这种方式,我们就不需要关注读写操作之间的数据冲突

每条记录在更新的时候都会同时记录一条回滚操作(回滚操作日志undo log)。同一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制(MVCC)。即通过回滚(rollback操作),可以回到前一个状态的值。InnoDB 为了解决这个问题,设计了 ReadView(可读视图)的概念.

如此一来不同的事务在并发过程中,SELECT 操作可以不加锁而是通过 MVCC 机制读取指定的版本历史记录,并通过一些手段保证保证读取的记录值符合事务所处的隔离级别,从而解决并发场景下的读写冲突。

mysql把每个操作都定义成一个事务,每开启一个事务,系统的事务版本号自动递增。每行记录都有两个隐藏列:创建版本号和删除版本号

ReadView

  • 系统版本号 SYS_ID:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
  • 事务版本号 TRX_ID :事务开始时的系统版本号。

MVCC 维护了一个 ReadView 结构,主要包含了当前系统未提交的事务列表 TRX_IDs {TRX_ID_1, TRX_ID_2, …},还有该列表的最小值 TRX_ID_MIN 和 TRX_ID_MAX。

在进行 SELECT 操作时,根据数据行快照的 TRX_ID 与 TRX_ID_MIN 和 TRX_ID_MAX 之间的关系,从而判断数据行快照是否可以使用:

  • TRX_ID < TRX_ID_MIN,表示该数据行快照时在当前所有未提交事务之前进行更改的,因此可以使用。
  • TRX_ID > TRX_ID_MAX,表示该数据行快照是在事务启动之后被更改的,因此不可使用。
  • TRX_ID_MIN <= TRX_ID <= TRX_ID_MAX,需要根据隔离级别再进行判断:
    • 提交读:如果 TRX_ID 在 TRX_IDs 列表中,表示该数据行快照对应的事务还未提交,则该快照不可使用。否则表示已经提交,可以使用。
    • 可重复读:都不可以使用。因为如果可以使用的话,那么其它事务也可以读到这个数据行快照并进行修改,那么当前事务再去读这个数据行得到的值就会发生改变,也就是出现了不可重复读问题。

在数据行快照不可使用的情况下,需要沿着 Undo Log 的回滚指针 ROLL_PTR 找到下一个快照,再进行上面的判断。

mysql在可重复读RR的隔离级别下如何避免幻读的

参考:

  • next-key锁: mysql 排它锁之行锁、间隙锁、后码锁
  • mvcc:
    • mysql mvcc实现原理
    • https://blog.csdn.net/DILIGENT203/article/details/100751755

知识点:

  • Record Lock:行锁, 锁直接加在索引记录上面,锁住的是key。当需要对表中的某条数据进行写操作(insert、update、delete、select for update)时,需要先获取记录的排他锁(X锁),这个就称为行锁。
  • Gap Lock:间隙锁,锁定索引记录间隙,确保索引记录的间隙不变。间隙锁是针对事务隔离级别为可重复读或以上级别而设计的。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。例如当一个事务执行语句SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;,则其它事务就不能在 t.c 中插入 15。
  • Next-Key Lock = Record Lock + Gap Lock , 它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。它锁定一个前开后闭区间,例如一个索引包含以下值:10, 11, 13, and 20,那么就需要锁定以下区间:
    (-∞, 10]
    (10, 11]
    (11, 13]
    (13, 20]
    (20, +∞)

默认情况下,InnoDB工作在可重复读隔离级别下,并且会以Next-Key Lock的方式对数据行进行加锁,这样可以有效防止幻读的发生。Next-Key Lock是行锁和间隙锁的组合,当InnoDB扫描索引记录的时候,会首先对索引记录加上Record Lock,再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙修改或者插入记录。

  • 快照读:简单的select操作,属于快照读,不加锁。(当然,也有例外,下面会分析)
    select * from table where ?;
  • 当前读:特殊的读操作,插入/更新/删除操作,属于当前读,需要加锁。
    • select * from table where ? lock in share mode;
    • select * from table where ? for update;
    • insert into table values (…);
    • update table set ? where ?;
    • delete from table where ?;

在MySQL中select称为快照读,不需要锁,而insert、update、delete、select for update则称为当前读,需要给数据加锁,幻读中的“读”即是针对当前读。
RR事务隔离级别允许存在幻读,但InnoDB RR级别却通过Gap锁避免了幻读

mysql如何实现避免幻读:

  1. 在快照读读情况下,mysql通过mvcc来避免幻读。
  2. 在当前读读情况下,mysql通过next-key lock来避免幻读。

正确理解InnoDB引擎RR隔离级别解决了幻读这件事

Mysql官方给出的幻读解释是:只要在一个事务中,第二次select多出了row就算幻读。

先看问题:
a事务先select,b事务insert确实会加一个gap锁,但是如果b事务commit,这个gap锁就会释放(释放后a事务可以随意dml操作),a事务再select出来的结果在MVCC下还和第一次select一样,接着a事务不加条件地update,这个update会作用在所有行上(包括b事务新加的),a事务再次select就会出现b事务中的新行,并且这个新行已经被update修改了,实测在RR级别下确实如此。

如果这样理解的话,Mysql的RR级别确实防不住幻读, 但是我们不能向上面这样理解, 我们得如下理解:

  • select * from t where a=1;属于快照读
  • select * from t where a=1 lock in share mode;属于当前读
  • 不能把快照读和当前读得到的结果不一样这种情况认为是幻读,这是两种不同的使用。所以mysql的rr级别是解决了幻读的。
  • 如上面问题所说,T1 select 之后 update,会将 T2 中 insert 的数据一起更新,那么认为多出来一行,所以防不住幻读。看着说法无懈可击,但是其实是错误的,InnoDB 中设置了快照读和当前读两种模式,如果只有快照读,那么自然没有幻读问题,但是如果将语句提升到当前读,那么 T1 在 select 的时候需要用如下语法: select * from t for update (lock in share mode) 进入当前读,那么自然没有 T2 可以插入数据这一回事儿了。

服务器开发自我修养专栏-HTTP深入浅出

Posted on 01-12-2021 | In Self-cultivation

HTTP与HTTPS

HTTP 协议考察 HTTP 协议的返回码、HTTP 的方法等。需要特别指出的是 HTTPS 加密的详细过程要非常透彻,不然容易产生一种感觉好像都清楚了,但是一问就有点说不清楚。

下面实例是一点典型的使用GET来传递数据的实例,
客户端请求:

GET /hello.txt HTTP/1.1
User-Agent: curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
Host: www.example.com
Accept-Language: en, mi

服务端响应:

HTTP/1.1 200 OK
Date: Mon, 27 Jul 2009 12:28:53 GMT
Server: Apache
Last-Modified: Wed, 22 Jul 2009 19:15:56 GMT
ETag: "34aa387-d-1568eb00"
Accept-Ranges: bytes
Content-Length: 51
Vary: Accept-Encoding
Content-Type: text/plain

输出结果:

Hello World! My payload includes a trailing CRLF.

客户端请求消息

客户端发送一个HTTP请求到服务器的请求消息包括以下格式:

  • 请求行(request line)
  • 请求头部(header)
  • 空行
  • 请求数据

由四个部分组成,下图给出了请求报文的一般格式。

服务器响应消息

HTTP响应也由四个部分组成,分别是:

  • 状态行
  • 消息报头
  • 空行
  • 响应正文

https

HTTPS 协议(HyperText Transfer Protocol over Secure Socket Layer):一般理解为HTTP+SSL/TLS,通过 SSL证书来验证服务器的身份,并为浏览器和服务器之间的通信进行加密。

那么SSL/TLS又是什么?

  • SSL(Secure Socket Layer,安全套接字层):1994年为 Netscape 所研发,SSL 协议位于 TCP/IP 协议与各种应用层协议之间,为数据通讯提供安全支持。
  • TLS(Transport Layer Security,传输层安全):其前身是 SSL,它最初的几个版本(SSL 1.0、SSL 2.0、SSL 3.0)由网景公司开发,1999年从 3.1 开始被 IETF 标准化并改名,发展至今已经有 TLS 1.0、TLS 1.1、TLS 1.2 三个版本。SSL3.0和TLS1.0由于存在安全漏洞,已经很少被使用到。TLS 1.3 改动会比较大,目前还在草案阶段,目前使用最广泛的是TLS 1.1、TLS 1.2。

https 不是一种新的协议,只是 http 的通信接口部分使用了 ssl 和 tsl 协议替代,加入了加密、证书、完整性保护的功能,下面解释一下加密和证书,如下图所示

对称加密

也叫共享密钥加密, 加密和解密公用一套秘钥,这样就会产生问题,已共享秘钥加密方式必须将秘钥传送给对方,但如果通信被监听,那么秘钥可能会被泄漏产生危险。
常见对称加密算法有des, aes

非对称加密

也叫公开秘钥加密, 使用一种非对称加密的算法,使用一对非对称的秘钥,一把叫做公有秘钥,一把叫做私有秘钥,在加密的时候,通信的一方使用公有秘钥进行加密,通信的另一方使用私有秘钥进行解密,利用这种方式不需要发送私有秘钥,也就不存在泄漏的风险了。
常见非对称加密算法有rsa

https 加密方式

因为公开秘钥加密的方式比共享秘钥加密的方式钥消耗 cpu 资源,https 采取了混合加密的方式,来结合两者的优点。

在秘钥交换阶段使用公开加密的方式,之后建立连接后使用共享秘钥加密方式进行加密,如下图。

为什么要使用证书

因为公开加密还存在一些问题就是无法证明公开秘钥的正确性(有可能被黑客中间替换成了黑客自己的公钥, 然后黑客伪装成服务器/客户端做中间转发),为了解决这个问题,https 采取了有数字证实认证机构和其相关机构颁发的公开秘钥证书,通信过程如下图所示。

解释一下上图的步骤:

  1. 服务器将自己的公开秘钥传到数字证书认证机构
  2. 数字证书认证机构使用自己的秘钥来对传来的服务器公钥进行加密,并颁发数字证书
  3. 服务器将传回的公钥证书发送给客户端,客户端使用数字机构颁发的公开秘钥来验证证书的有效性,以及公开秘钥的真实性
    • 证书签名是先将证书信息(证书机构名称、有效期、拥有者、拥有者公钥)进行hash,再用CA的私有密钥对hash值加密而生成的。
    • 所以拦截者虽然可以拦截并篡改证书信息(主要是拥有者和拥有者的公钥),但是由于拦截者没有CA的私钥,所以无法生成正确的签名,从而导致客户端拿到签名后,用CA公有密钥对证书签名解密后值与用证书计算出来的实际hash值不一样,从而得不到客户端信任。(其实这个ca公钥和私钥也就是非对称加密的思想了)
  4. 客户端使用服务器的公开秘钥进行消息加密,后发送给服务器。
  5. 服务器使用私有秘钥进行解密。

浏览器在安装的时候会内置可信的数字证书机构的公开秘钥,如下图所示。

这就是为什么我们使用自己生成的证书的时候会产生安全警告的原因。

再附一张 https 的具体通信步骤和图解。

cookie

服务器发送的响应报文包含 Set-Cookie 首部字段,客户端得到响应报文后把 Cookie 内容保存到浏览器中。

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

客户端之后对同一个服务器发送请求时,会从浏览器中取出 Cookie 信息并通过 Cookie 请求首部字段发送给服务器。

GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

Domain 标识指定了哪些主机可以接受 Cookie。如果不指定,默认为当前文档的主机(不包含子域名)。如果指定了 Domain,则一般包含子域名。例如,如果设置 Domain=mozilla.org,则 Cookie 也包含在子域名中(如 developer.mozilla.org)。

Path 标识指定了主机下的哪些路径可以接受 Cookie(该 URL 路径必须存在于请求 URL 中)。以字符 %x2F (“/“) 作为路径分隔符,子路径也会被匹配。例如,设置 Path=/docs,则以下地址都会匹配:

  • /docs
  • /docs/Web/
  • /docs/Web/HTTP

session如何保存较好

一个用户的 Session 信息如果存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器,由于服务器没有用户的 Session 信息,那么该用户就需要重新进行登录等操作..有什么好的解决方案呢?
Session Server使用一个单独的服务器存储 Session 数据,可以使用传统的 MySQL,也使用 Redis 或者 Memcached 这种内存型数据库。

  • 优点:
    为了使得大型网站具有伸缩性,集群中的应用服务器通常需要保持无状态,那么应用服务器不能存储用户的会话信息。Session Server 将用户的会话信息单独进行存储,从而保证了应用服务器的无状态。
  • 缺点:
    需要去实现存取 Session 的代码

cookie和session和token的区别

  • 由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识具体的用户,这个机制就是Session.典型的场景比如购物车,当你点击下单按钮时,由于HTTP协议无状态,所以并不知道是哪个用户操作的,所以服务端要为特定的用户创建了特定的Session,用用于标识这个用户,并且跟踪用户,这样才知道购物车里面有几本书。这个Session是保存在服务端的,有一个唯一标识。在服务端保存Session的方法很多,内存、数据库、文件都有。集群的时候也要考虑Session的转移,在大型的网站,一般会有专门的Session服务器集群,用来保存用户会话,这个时候 Session 信息都是放在内存的,使用一些缓存服务比如Memcached之类的来放 Session。
  • 思考一下服务端如何识别特定的客户?这个时候Cookie就登场了。每次HTTP请求的时候,客户端都会发送相应的Cookie信息到服务端。实际上大多数的应用都是用 Cookie 来实现Session跟踪的,第一次创建Session的时候,服务端会在HTTP协议中告诉客户端,需要在 Cookie 里面记录一个Session ID,以后每次请求把这个会话ID发送到服务器,我就知道你是谁了。有人问,如果客户端的浏览器禁用了 Cookie 怎么办?一般这种情况下,会使用一种叫做URL重写的技术来进行会话跟踪,即每次HTTP交互,URL后面都会被附加上一个诸如 sid=xxxxx 这样的参数,服务端据此来识别用户。
  • Cookie其实还可以用在一些方便用户的场景下,设想你某次登陆过一个网站,下次登录的时候不想再次输入账号了,怎么办?这个信息可以写到Cookie里面,访问网站的时候,网站页面的脚本可以读取这个信息,就自动帮你把用户名给填了,能够方便一下用户。这也是Cookie名称的由来,给用户的一点甜头。所以,总结一下:Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中;Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。
  • 为什么需要token来替代session机制? 因为session的存储对服务器说是一个巨大的开销, 严重的限制了服务器扩展能力, 比如说我用两个机器组成了一个集群, 小 F 通过机器 A 登录了系统, 那 session id 会保存在机器 A 上, 假设小 F 的下一次请求被转发到机器 B 怎么办? 机器 B 可没有小 F 的 session id 啊。有时候会采用一点小伎俩: session sticky , 就是让小 F 的请求一直粘连在机器 A 上, 但是这也不管用, 要是机器 A 挂掉了, 还得转到机器 B 去。

接下来我们介绍事实上的token标准JWT

JWT

sessionId 的方式本质是把用户状态信息维护在 server 端,token 的方式就是把用户的状态信息加密成一串 token 传给前端,然后每次发请求时把 token 带上,传回给服务器端;服务器端收到请求之后,解析 token 并且验证相关信息(用jwt的header里的加密方式然后根据自己的不公开的密钥把jwt中的payload用加密一下得到一个签名 s, 然后用s对比看看是不是跟jwt里的signature相等, 相等则说明token对了);

备注: 对于数据校验,专门的消息认证码生成算法, HMAC - 一种使用单向散列函数构造消息认证码的方法,其过程是不可逆的、唯一确定的,并且使用密钥来生成认证码,其目的是防止数据在传输过程中被篡改或伪造。将原始数据与认证码一起传输,数据接收端将原始数据使用相同密钥和相同算法再次生成认证码,与原有认证码进行比对,校验数据的合法性。

所以跟第一种登录方式最本质的区别是:通过解析 token 的计算时间换取了 session 的存储空间

业界通用的加密方式是 jwt, jwt 的具体格式如图:

简单的介绍一下 jwt,它主要由 3 部分组成:

header 头部
{
"alg": "HS256",
"typ": "JWT"
}
payload 负载
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1555341649998
}
signature 签名
{
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
) secret base64 encoded
}

  • header
    header 里面描述加密算法和 token 的类型,类型一般都是 JWT;
  • payload
    里面放的是用户的信息,也就是第一种登录方式中需要维护在服务器端 session 中的信息;
  • signature
    是对前两部分的签名,也可以理解为加密;实现需要一个密钥(secret),这个 secret 只有服务器才知道,然后使用 header 里面的算法按照如下方法来加密:
    HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret)

总之,最后的 jwt = base64url(header) + "." + base64url(payload) + "." + signature
jwt 可以放在 response 中返回,也可以放在 cookie 中返回,这都是具体的返回方式,并不重要。
客户端发起请求时,官方推荐放在 HTTP header 中:

Authorization: Bearer <token>

这样子确实也可以解决 cookie 跨域(比如移动平台上对cookie支持不好)的问题,不过具体放在哪儿还是根据业务场景来定,并没有一定之规。

jwt过期了如何刷新

前面讲的 Token,都是 Access Token,也就是访问资源接口时所需要的 Token,还有另外一种 Token,Refresh Token,通常情况下,Refresh Token 的有效期会比较长,而 Access Token 的有效期比较短,当 Access Token 由于过期而失效时,使用 Refresh Token 就可以获取到新的 Access Token,如果 Refresh Token 也失效了,用户就只能重新登录了。

在 JWT 的实践中,引入 Refresh Token,将会话管理流程改进如下:

  1. 客户端使用用户名密码进行认证
  2. 服务端生成有效时间较短的 Access Token(例如 10 分钟),和有效时间较长的 Refresh Token(例如 7 天)
  3. 客户端访问需要认证的接口时,携带 Access Token
  4. 如果 Access Token 没有过期,服务端鉴权后返回给客户端需要的数据
  5. 如果携带 Access Token 访问需要认证的接口时鉴权失败(例如返回 401 错误),则客户端使用 Refresh Token 向刷新接口申请新的 Access Token
  6. 如果 Refresh Token 没有过期,服务端向客户端下发新的 Access Token
  7. 客户端使用新的 Access Token 访问需要认证的接口

常见的HTTP相应状态码

总之:(一般标准用法是这样用哈, 但是真的写代码的时候其实跟get/post/put一样, 想怎么用全看自己, 前后端开发人员协商好就行)

  • 1XX:消息
  • 2XX:成功
  • 3XX:重定向
  • 4XX:请求错误
  • 5XX、6XX:服务器错误

常见状态代码、状态描述的说明如下:

  • 200 OK:
    请求已成功,请求所希望的响应头或数据体将随此响应返回。实际的响应将取决于所使用的请求方法。在GET请求中,响应将包含与请求的资源相对应的实体。在POST请求中,响应将包含描述或操作结果的实体。[7]
  • 301 Moved Permanently:
    被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个URI之一。如果可能,拥有链接编辑功能的客户端应当自动把请求的地址修改为从服务器反馈回来的地址。[19]除非额外指定,否则这个响应也是可缓存的。
    新的永久性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。
    如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。
    注意:对于某些使用HTTP/1.0协议的浏览器,当它们发送的POST请求得到了一个301响应的话,接下来的重定向请求将会变成GET方式。
  • 400 Bad Request
    由于明显的客户端错误(例如,格式错误的请求语法,太大的大小,无效的请求消息或欺骗性路由请求),服务器不能或不会处理该请求。[31]
  • 401 Unauthorized(RFC 7235)
    参见:HTTP基本认证、HTTP摘要认证
    类似于403 Forbidden,401语义即“未认证”,即用户没有必要的凭据。[32]该状态码表示当前请求需要用户验证。该响应必须包含一个适用于被请求资源的WWW-Authenticate信息头用以询问用户信息。客户端可以重复提交一个包含恰当的Authorization头信息的请求。[33]如果当前请求已经包含了Authorization证书,那么401响应代表着服务器验证已经拒绝了那些证书。如果401响应包含了与前一个响应相同的身份验证询问,且浏览器已经至少尝试了一次验证,那么浏览器应当向用户展示响应中包含的实体信息,因为这个实体信息中可能包含了相关诊断信息。
    注意:当网站(通常是网站域名)禁止IP地址时,有些网站状态码显示的401,表示该特定地址被拒绝访问网站。
  • 403 Forbidden
    主条目:HTTP 403
    服务器已经理解请求,但是拒绝执行它。与401响应不同的是,身份验证并不能提供任何帮助,而且这个请求也不应该被重复提交。如果这不是一个HEAD请求,而且服务器希望能够讲清楚为何请求不能被执行,那么就应该在实体内描述拒绝的原因。当然服务器也可以返回一个404响应,假如它不希望让客户端获得任何信息。
  • 404 Not Found
    主条目:HTTP 404
    请求失败,请求所希望得到的资源未被在服务器上发现,但允许用户的后续请求。[35]没有信息能够告诉用户这个状况到底是暂时的还是永久的。假如服务器知道情况的话,应当使用410状态码来告知旧资源因为某些内部的配置机制问题,已经永久的不可用,而且没有任何可以跳转的地址。404这个状态码被广泛应用于当服务器不想揭示到底为何请求被拒绝或者没有其他适合的响应可用的情况下。
  • 500 Internal Server Error
    通用错误消息,服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。没有给出具体错误信息。[59]
  • 503 Service Unavailable
    由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是暂时的,并且将在一段时间以后恢复。[62]如果能够预计延迟时间,那么响应中可以包含一个Retry-After头用以标明这个延迟时间。如果没有给出这个Retry-After信息,那么客户端应当以处理500响应的方式处理它。

get和post的本质区别

从设计初衷上来说,GET 用来实现从服务端取数据,POST 用来实现向服务端提出请求对数据做某些修改,也因此如果你向nginx用post请求静态文件,nginx会直接返回 405 not allowed,但是服务端毕竟是人实现的,你可以让 POST 做 GET 相同的事情

get请求的参数一般放在url中,但是浏览器和服务器程序对url长度还是有限制的。
post请求的参数一般放在body,你硬要放到url中也可以。

在RESTful风格中,get用于从服务器获获取数据,而post用于创建数据

Connection: keep-alive

在早期的HTTP/1.0中,每次http请求都要创建一个连接,而创建连接的过程需要消耗资源和时间,为了减少资源消耗,缩短响应时间,就需要重用连接。在后来的HTTP/1.0中以及HTTP/1.1中,引入了重用连接的机制,就是在http请求头中加入Connection: keep-alive来告诉对方这个请求响应完成后不要关闭,下一次咱们还用这个请求继续交流。协议规定HTTP/1.0如果想要保持长连接,需要在请求头中加上Connection: keep-alive,而HTTP/1.1默认是支持长连接的,有没有这个请求头都行。

要实现长连接很简单,只要客户端和服务端都保持这个http长连接即可。但问题的关键在于保持长连接后,浏览器如何知道服务器已经响应完成?在使用短连接的时候,服务器完成响应后即关闭http连接,这样浏览器就能知道已接收到全部的响应,同时也关闭连接(TCP连接是双向的)。

在使用长连接的时候,响应完成后服务器是不能关闭连接的,那么它就要在响应头中加上特殊标志告诉浏览器已响应完成。一般情况下这个特殊标志就是Content-Length,来指明响应体的数据大小,比如Content-Length: 120表示响应体内容有120个字节,这样浏览器接收到120个字节的响应体后就知道了已经响应完成。

由于Content-Length字段必须真实反映响应体长度,但实际应用中,有些时候响应体长度并没那么好获得,例如响应体来自于网络文件,或者由动态语言生成。这时候要想准确获取长度,只能先开一个足够大的内存空间,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。这时候Transfer-Encoding: chunked响应头就派上用场了,该响应头表示响应体内容用的是分块传输,此时服务器可以将数据一块一块地分块响应给浏览器而不必一次性全部响应,待浏览器接收到全部分块后就表示响应结束。

以分块传输一段文本内容:“人的一生总是在追求自由的一生 So easy”来说明分块传输的过程,如下图所示:

url编码urlencode是什么

RFC3986文档规定,Url中只允许包含英文字母(a-zA-Z)、数字(0-9)、-_.~4个特殊字符以及所有保留字符。
那如何对Url中的非法字符进行编码呢?
  
Url编码通常也被称为百分号编码(Url Encoding,also known as percent-encoding),是因为它的编码方式非常简单,使用%百分号加上两位的字符——0123456789ABCDEF——代表一个字节的十六进制形式。Url编码默认使用的字符集是US-ASCII。例如a在US-ASCII码中对应的字节是0x61,那么Url编码之后得到的就是%61,我们在地址栏上输入http://g.cn/search?q=%61%62%63,

实际上就等同于在google上搜索abc了。又如@符号在ASCII字符集中对应的字节为0x40,经过Url编码之后得到的是%40。
  
  对于非ASCII字符,需要使用ASCII字符集的超集进行编码得到相应的字节,然后对每个字节执行百分号编码。对于Unicode字符,RFC文档建议使用utf-8对其进行编码得到相应的字节,然后对每个字节执行百分号编码。如”中文”使用UTF-8字符集得到的字节为0xE4 0xB8 0xAD 0xE6 0x96 0x87,经过Url编码之后得到”%E4%B8%AD%E6%96%87”。
  
  如果某个字节对应着ASCII字符集中的某个非保留字符,则此字节无需使用百分号表示。例如”Url编码”,使用UTF-8编码得到的字节是0x55 0x72 0x6C 0xE7 0xBC 0x96 0xE7 0xA0 0x81,由于前三个字节对应着ASCII中的非保留字符”Url”,因此这三个字节可以用非保留字符”Url”表示。最终的Url编码可以简化成”Url%E7%BC%96%E7%A0%81” ,当然,如果你用”%55%72%6C%E7%BC%96%E7%A0%81”也是可以的。

很多HTTP监视工具或者浏览器地址栏等在显示Url的时候会自动将Url进行一次解码(使用UTF-8字符集),这就是为什么当你在Firefox中访问Google搜索中文的时候,地址栏显示的Url包含中文的缘故。但实际上发送给服务端的原始Url还是经过编码的。

服务器开发自我修养专栏-Linux网络编程

Posted on 12-28-2020 | In Self-cultivation

Linux网络编程

I/O模式 水平触发 边缘触发
epoll ✓ ✓
select/poll ✓
信号驱动 ✓

select

一个常见的select例子如下:

#include "unp.h"

void
str_cli(FILE *fp, int sockfd)
{
int maxfdp1, stdineof;
fd_set rset;
char buf[MAXLINE];
int n;

stdineof = 0;
FD_ZERO(&rset);
for ( ; ; ) {
if (stdineof == 0)
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);

if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if ( (n = Read(sockfd, buf, MAXLINE)) == 0) {
if (stdineof == 1)
return; /* normal termination */
else
err_quit("str_cli: server terminated prematurely");
}

Write(fileno(stdout), buf, n);
}

if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if ( (n = Read(fileno(fp), buf, MAXLINE)) == 0) {
stdineof = 1;
Shutdown(sockfd, SHUT_WR); /* send FIN */
FD_CLR(fileno(fp), &rset);
continue;
}

Writen(sockfd, buf, n);
}
}
}

参考select poll epoll的区别

可以看出select的缺点如下:

  • (遍)select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  • fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义,所以只能监听少于 FD_SETSIZE 数量的描述符。FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译
  • (内)内核/用户空间内存拷贝问题,每次调用select都需要将全部描述符从应用进程缓冲区复制到内核缓冲区
  • (数)单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;
  • select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO,那么之后再次select调用还是会将这些文件描述符通知进程。
  • 相比于select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但其他三个缺点依然存在。

epoll

一个常见的epoll使用例子:

while (numOpenFds > 0) {
/* Fetch up to MAX_EVENTS items from the ready list of the
epoll instance */

printf("About to epoll_wait()\n");
ready = epoll_wait(epfd, evlist, MAX_EVENTS, -1);
if (ready == -1) {
if (errno == EINTR)
continue; /* Restart if interrupted by signal */
else
errExit("epoll_wait");
}

printf("Ready: %d\n", ready);

/* Deal with returned list of events */

for (j = 0; j < ready; j++) {
printf(" fd=%d; events: %s%s%s\n", evlist[j].data.fd,
(evlist[j].events & EPOLLIN) ? "EPOLLIN " : "",
(evlist[j].events & EPOLLHUP) ? "EPOLLHUP " : "",
(evlist[j].events & EPOLLERR) ? "EPOLLERR " : "");

if (evlist[j].events & EPOLLIN) {
s = read(evlist[j].data.fd, buf, MAX_BUF);
if (s == -1)
errExit("read");
printf(" read %d bytes: %.*s\n", s, s, buf);

} else if (evlist[j].events & (EPOLLHUP | EPOLLERR)) {

/* After the epoll_wait(), EPOLLIN and EPOLLHUP may both have
been set. But we'll only get here, and thus close the file
descriptor, if EPOLLIN was not set. This ensures that all
outstanding input (possibly more than MAX_BUF bytes) is
consumed (by further loop iterations) before the file
descriptor is closed. */

printf(" closing fd %d\n", evlist[j].data.fd);
// 关闭一个文件描述符会自动的将其从所有的 epoll 实例的兴趣列表中移除
if (close(evlist[j].data.fd) == -1)
errExit("close");
numOpenFds--;
}
}
}

epoll的设计和实现select完全不同。epoll把原先的select/poll调用分成了3个部分:

  1. 调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
  2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字
  3. 调用epoll_wait收集发生的事件的连接

总结:

  • epoll_ctl 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait 便可以得到事件完成的描述符。
  • 从上面的描述可以看出,epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。
  • epoll 仅适用于 Linux OS。
  • epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。

水平触发与边缘触发的区别

默认情况下 epoll 提供的是水平触发通知.要使用边缘触发通知,我们在调用epoll_ctl()时在ev.events字段中指定EPOLLET标志.

例如 :

struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) == -1)
errExit("epoll_ctl");

我们通过一个例子来说明epoll的水平触发和边缘触发通知之间的区别。
假设我们使用epoll来监视一个套接字上的输入(EPOLLIN),接下来会发生如下的事件。

  1. 套接字上有输入到来。
  2. 我们调用一次epoll_wait()。无论我们采用的是水平触发还是边缘触发通知,该调用
    都会告诉我们套接字已经处于就绪态了。
  3. 再次调用epoll_wait()。
    • 如果我们采用的是水平触发通知,那么第二个epoll_wait()调用将告诉我们套接字处于就绪态。
    • 而如果我们采用边缘触发通知,那么第二个epoll_wait()调用将阻塞,因为自从上一次调用epoll_wait()以来并没有新的输入到来。边缘触发通知通常和非阻塞的文件描述符结合使用。因而,采用epoll的边缘触发通知机制的程序基本框架如下:
      1. 让所有待监视的文件描述符都成为非阻塞的。
      2. 通过epoll_ctl()构建epoll的兴趣列表。
      3. 通过epoll_wait()取得处于就绪态的描述符列表。
      4. 针对每一个处于就绪态的文件描述符,不断进行I/O处理直到相关的系统调用( 例如read()、write(),recv()、send()或accept() )返回EAGAIN或EWOULDBLOCK错误。

水平触发需要处理的问题

使用linux epoll模型,水平触发模式(Level-Triggered);当socket可写时,会不停的触发socket可写的事件,如何处理?

  • 第一种最普通的方式:
    当需要向socket写数据时,将该socket加入到epoll模型(epoll_ctl);等待可写事件。
    接收到socket可写事件后,调用write()或send()发送数据。。。
    当数据全部写完后, 将socket描述符移出epoll模型。

    这种方式的缺点是: 即使发送很少的数据,也要将socket加入、移出epoll模型。有一定的操作代价。

  • 第二种方式,(是本人的改进方案, 叫做directly-write)
    向socket写数据时,不将socket加入到epoll模型;而是直接调用send()发送;
    只有当或send()返回错误码EAGAIN(系统缓存满),才将socket加入到epoll模型,等待可写事件后(表明系统缓冲区有空间可以写了),再发送数据。
    全部数据发送完毕,再移出epoll模型。

    这种方案的优点: 当用户数据比较少时,不需要epool的事件处理。
    在高压力的情况下,性能怎么样呢?
    对一次性直接写成功、失败的次数进行统计。如果成功次数远大于失败的次数, 说明性能良好。(如果失败次数远大于成功的次数,则关闭这种直接写的操作,改用第一种方案。同时在日志里记录警告)
    在我自己的应用系统中,实验结果数据证明该方案的性能良好。

    事实上,网络数据可分为两种到达/发送情况:
    一是分散的数据包, 例如每间隔40ms左右,发送/接收3-5个 MTU(或更小,这样就没超过默认的8K系统缓存)。
    二是连续的数据包, 例如每间隔1s左右,连续发送/接收 20个 MTU(或更多)。

  • 第三种方式: 使用Edge-Triggered(边沿触发),这样socket有可写事件,只会触发一次。
    可以在应用层做好标记。以避免频繁的调用 epoll_ctl( EPOLL_CTL_ADD, EPOLL_CTL_MOD)。 这种方式是epoll 的 man 手册里推荐的方式, 性能最高。但如果处理不当容易出错,事件驱动停止。

epoll实现细节

epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已,如何能不高效?!

那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时次次返回这个句柄,而ET模式仅在第一次返回。

这件事怎么做到的呢?当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait干了件事,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的。

select 和 epoll的区别

select函数,必须得清楚select跟linux特有的epoll的区别, 有三点(遍内数):

  • 遍历 : 每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大;当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd, 每次只需要简单的从列表里取出就行了
  • 内存拷贝 : select,poll每次调用都要把fd集合从用户态往内核态拷贝一次; epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次
  • 数量限制 : select默认只支持1024个;epoll并没有最大数目限制

非阻塞的connect和accept

  • 非阻塞connect为啥要用?怎么用?
    • 为啥要用: 因为connect是比较耗时的, 所以我们希望可以在connecting的时候并行的做点其他的事
    • 怎么用: 调用非阻塞connect之后会立马返回EINPROCESS错误, 然后我们去epoll注册一个可写事件, 等待此套接字可写我们判断一下如果不是socket发生异常错误则即为connect连上了
  • 非阻塞accept有啥用, 怎么用?为啥要用?
    • 为啥要用: 如果调用阻塞accept,这样如果在select检测到有连接请求,但在调用accept之前,这个请求断开了,然后调用accept的时候就会阻塞在哪里,除非这时有另外一个连接请求,如果没有,则一直被阻塞在accept调用上, 无法处理任何其他已就绪的描述符。
    • 怎么用: 我们去epoll注册一个监听套接字的fd可读事件, 等待此套接字的fd可写我们判断一下如果不是socket发生异常错误则即为准备好了一个新连接
  • 注意 : 当socket异常错误的时候socket是可读并可写的, 所以在非阻塞connect(判断是否可写)/accept(判断是否可读)的时候要特别注意这种情况, 要用getsockopt函数, 使用SO_ERROR选项来检查处理.

阻塞和非阻塞的send和recv和sendto和recvfrom

注意: 首先需要说明的是,不管阻塞还是非阻塞,在发送时都会将数据从应用进程缓冲区拷贝到内核套接字发送缓冲区(UDP并没有实际存在这个内核套接字发送缓冲区, UDP的套接字缓冲区大小仅仅是可写到该套接字UDP数据包的大小上限, TCP/UDP都可以用SO_SNDBUF选项来更改该内核缓冲区大小)。

  • 发送, 我们发送选用send(这里特指TCP)以及sendto(这里特指UDP)来描述
    • 阻塞
      • 在阻塞模式下send操作将会等待所有数据均被拷贝到发送缓冲区后才会返回。阻塞的send操作返回的发送大小,必然是你参数中的发送长度的大小。
      • 在阻塞模式下的sendto操作不会阻塞。
        关于这一点的原因在于:UDP并没有真正的发送缓冲区,它所做的只是将应用缓冲区拷贝给下层协议栈,在此过程中加上UDP头,IP头,所以实际不存在阻塞。
    • 非阻塞
      • 在非阻塞模式下send操作调用会立即返回。
        关于立即返回大家都不会有异议。还是拿阻塞send的那个例子来看,当缓冲区只有192字节,但是却需要发送2000字节时,此时调用立即返回,并得到返回值为192。从中可以看到,非阻塞send仅仅是尽自己的能力向缓冲区拷贝尽可能多的数据,因此在非阻塞下send才有可能返回比你参数中的发送长度小的值。
        如果缓冲区没有任何空间时呢?这时肯定也是立即返回,但是你会得到WSAEWOULDBLOCK/EWOULDBLOCK 的错误,此时表示你无法拷贝任何数据到缓冲区,你最好休息一下再尝试发送。
      • 在非阻塞模式下sendto操作 不会阻塞(与阻塞一致,不作说明)。
  • 接收, 接收选用recv(这里特指TCP)以及recvfrom(这里特指UDP)来描述
    • 阻塞
      • 在阻塞模式下recv,recvfrom操作将会阻塞 到缓冲区里有至少一个字节(TCP)或者一个完整UDP数据报才返回。
      • 在没有数据到来时,对它们的调用都将处于睡眠状态,不会返回。
    • 非阻塞
      • 在非阻塞模式下recv,recvfrom操作将会立即返回。
      • 如果缓冲区有任何一个字节数据(TCP)或者一个完整UDP数据报,它们将会返回接收到的数据大小。而如果没有任何数据则返回错误 WSAEWOULDBLOCK/EWOULDBLOCK。

reuseaddr和reuseport

  • reuseaddr的作用?
    • 参考 https://zhuanlan.zhihu.com/p/35367402
    • 主要是用于绑定TIME_WAIT状态的地址: 一个非常现实的问题是,假如一个systemd托管的service异常退出了,留下了TIME_WAIT状态的socket,那么systemd将会尝试重启这个service。但是因为端口被占用,会导致启动失败,造成两分钟的服务空档期,systemd也可能在这期间放弃重启服务。但是在设置了SO_REUSEADDR以后,处于TIME_WAIT状态的地址也可以被绑定,就杜绝了这个问题。因为TIME_WAIT其实本身就是半死状态,虽然这样重用TIME_WAIT可能会造成不可预料的副作用,但是在现实中问题很少发生,所以也忽略了它的副作用
  • reuseport有啥用?

    • SO_REUSEPORT使用场景:linux kernel 3.9 引入了最新的SO_REUSEPORT选项,使得多进程或者多线程创建多个绑定同一个ip:port的监听socket,提高服务器的接收链接的并发能力,程序的扩展性更好;此时需要设置SO_REUSEPORT(注意所有进程都要设置才生效)。

      setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT,(const void *)&reuse , sizeof(int));

      目的:每一个进程有一个独立的监听socket,并且bind相同的ip:port,独立的listen()和accept();提高接收连接的能力。(例如nginx多进程同时监听同一个ip:port)
      解决的问题:

      • 避免了应用层多线程或者进程监听同一ip:port的“惊群效应”。
      • 内核层面实现负载均衡,保证每个进程或者线程接收均衡的连接数。
      • 只有effective-user-id相同的服务器进程才能监听同一ip:port (安全性考虑)
123456789101112131415161718…37
Mike

Mike

🚙 🚗 💨 💨 If you want to create a blog like this, just follow my open-source project, "hexo-theme-neo", click the GitHub button below and check it out ^_^ . It is recommended to use Chrome, Safari, or Edge to read this blog since this blog was developed on Edge (Chromium kernel version) and tested on Safari.

11 categories
289 posts
111 tags
about
GitHub Spotify
© 2013 - 2025 Mike