连接池和协程池为何能提升并发能力?

你有没有发现,“内存池”和“进程池”都带有“池”字?其实,这两种技术都属于“池化技术”。它通常是由系统预先分配一批资源并循环利用,达到资源“池化”的目的,以解决高并发下,由于大量分配资源,导致性能开销过大的问题。

一句话,池化技术的核心是“预分配”和“循环使用”。这就像食堂在用餐高峰期安排餐具一样。食堂通常会在餐厅提前准备一批餐具,并在用餐过程中安排人专门清洗餐具,然后循环使用。食堂采用这种方法,节省了开支,原来只有 50 套的餐具就能保障 100 人有序就餐。池化技术也是类似的作用。

那么,除了前面提到的内存池和进程池外,都还有哪些池化技术呢?常用的池化技术还有连接池和协程池。它们解决什么具体问题呢?

连接池的作用
连接池是指预先分配一批连接,并将它们放入一个缓冲区中循环使用,形成池化效应。以秒杀为例,秒杀接口服务并发非常高,如果不用连接池,会导致什么后果?

在介绍 KV 存储的时候,我提到过秒杀系统使用 Redis 缓存活动信息。假如秒杀接口服务与 Redis 只有一个连接,平均每次请求 Redis 耗时 10ms,一秒钟能请求多少次呢?没错,一个连接一秒钟最多只能处理 100 次请求。

在秒杀系统中,除了活动信息外,秒杀库存信息也缓存在 Redis 中,而且需要支持 1 万以上并发能力。活动信息加上库粗存信息,分摊到 50 个秒杀接口节点上,平均每个节点向 Redis 发起的请求可能超过 300 QPS,远超过单个连接的处理能力。

这就会出现第一个问题:单连接无法承载高并发。

可能你会说,既然复用单个连接无法承载高并发,那就每次请求都新建连接嘛!想法很好,但现实很残酷。建立连接的时候,TCP 需要经历三次握手,假如网络延迟是 5 ms,三次握手就耗费 15ms,这比一次请求来回的时间都长了。其次,如果每次请求都建立连接,还需要考虑关闭连接,以免连接数过多压垮 Redis。而关闭连接的过程涉及 TCP 四次挥手,这又是一笔时间开销。

所以,如果不用连接池就会出现第二个问题:每次请求建立、关闭连接会导致请求延迟增加,还有可能把 Redis 压垮。

另外,如果高并发下频繁地建立、关闭连接,会导致操作系统耗费过多 CPU 用于分配、回收系统资源。

那我们该如何设计连接池来解决这三个问题呢?

通常,连接池有几个参数:最小连接数、空闲连接数、最大连接数。为何这么设计呢?请看下图:

16
最小连接数通常用于控制当前连接数的最小值,如果连接数小于最小值,遇到突发流量容易导致性能问题。

空闲连接数就是用于控制连接池中空闲连接的数量,如果超过这个值,意味着浪费资源,需要关闭多余连接;如果低于这个值,则可能无法应对突发流量,需要分配新的空闲连接。

有关空闲连接的分配,可以通过定时器来控制。设置时,要尽量保障秒杀服务向 Redis 发起请求的时候,有足够的空闲连接,这样可以减少建立连接的时间和资源开销。通常是由独立的线程定时检查空闲连接是否小于某个值,比如每隔 1 秒钟检查空闲连接数是否小于 2,是的话就新建一批空闲连接。

最大连接数通常用于控制系统中连接数不超过最大值,以免大量连接将 Redis 压垮。

秒杀服务在启动时,将按照配置文件中的参数初始化好连接数池,比如设置初始连接数为最小连接数。当秒杀服务需要发起一个 Redis 请求时,会先尝试从连接池中获取连接,如果获取不到,则会建立一个新的连接。

当连接数超过最大值时, 请求就会阻塞,等待其他请求归还连接。在请求完 Redis 后,秒杀服务需要将连接放回到连接池中。如果空闲连接超过参数指定的数,秒杀服务会直接关闭该连接。

另外,为了确保 Redis 集群中的多个实例负载均衡,连接池中的连接也需要做负载均衡。

如何从连接池中获取连接,用完后又如何将连接放回到连接池中呢?

通常可以采用循环队列来保存空闲连接。使用的时候,可以从队列头部取出连接,用完后将空闲连接放到队列尾部。在 Go 语言中,还有另外一种方法,那就是利用带缓冲区的 channel 来充当队列。这个实现起来非常简单,在代码实战环节我会详细给你介绍。

协程池的作用
协程池,简单来说就是由多个协程实现的池化技术。

如果你了解过 Linux 内核,应该知道 Linux 内核中是以进程为单元来调度资源的,线程也是轻量级进程。可以说,进程、线程都是由内核来创建并调度。而协程是由应用程序创建出来的任务执行单元,比如 Go 语言中的协程“goroutine”。协程本身是运行在线程上,由应用程序自己调度,它是比线程更轻量的执行单元。

相比进程、线程,协程有什么优点呢?那得从高并发下进程、线程的性能说起。

当应用程序需要创建一个进程或者线程时,它需要先调用系统函数向内核提交申请。接着它会从用户态切换到内核态,由内核创建进程和线程,然后再从内核态切换到用户态。

在用户态与内核态来回切换的过程中,操作系统需要保存大量的上下文信息。具体来说,从用户态切换到内核态时,内核需要将 CPU 各寄存器中的数据写入到栈内存;当程序从内核态恢复到用户态时,需要内核将前面保存的寄存器数据从栈内存加载到 CPU 中。

内核创建进程和线程的时候,需要分配 PCB (Processing Control Block,进程控制块),其作用是用于保存进程和线程的运行状态。也就是说,进程和线程在内核中是占用内存空间的。并且,在分配 PCB 的过程也会耗费额外的 CPU 资源。

特别是在父进程创建子进程的时候,子进程会继承父进程的内存状态。当子进程修改某块内存的数据时,会触发写时拷贝,系统会为子进程分配新的内存空间,这意味着运行时子进程有额外的 CPU 开销。另外,每个线程也有自己的栈空间,比如 Linux 下默认栈空间大小是 8MB,也就是说即使线程程什么事情都不做,也会白白浪费 8MB 内存。

还有,前面提到了创建进程和线程时会发生状态切换,主要是因为内核中的任务是以内核线程的方式来运行的。而且内核线程是可以动态创建的,可以超过 CPU 线程数。但是,一个 CPU 线程在同一时刻只能运行一个内核线程,这就涉及内核线程间上下文切换的问题了。

要知道,上下文切换是很耗费 CPU 资源的。首先,上下文切换时 CPU 需要耗费更多的时间保存或者加载数据,这会导致 CPU 有效使用率降低。其次,上下文切换后,需要加载新的数据,可能导致 CPU 中的 L1、L2 缓存中数据失效,性能打折扣。

在高并发场景下,每创建、销毁一个线程,带来的 CPU 和内存开销都不小。为了解决这些问题,协程诞生了。

在 Go 语言中,一个协程初始内存空间是 2KB,相比线程和进程来说要小很多。协程的创建和销毁完全是在用户态执行的,不涉及用户态和内核态的切换。另外,协程完全由应用程序在用户态下调用,不涉及内核态的上下文切换。协程切换时由于不需要处理线程状态,需要保存的上下文也很少,速度很快。

既然协程的创建、切换、销毁性能已经很高了,为何还要做协程池呢?

前面提到了,每个协程初始内存大小为 2K。假如每个请求都创建一个协程,当秒杀服务单机并发达到 10 万的时候,会带来近 200MB 的内存分配和占用,可能会带来 GC 回收内存的问题。

另外,虽然协程创建很快,但还是要耗费时间的,比如需要分配内存,需要初始化协程状态等。在秒杀这种高并发场景下,每个请求哪怕是增加 1 毫秒延迟,也会给服务带来不小的 CPU 开销。

那我们如何设计协程池呢?

在 Go 语言中,协程池的实现方法有两种:抢占式和调度式。

抢占式协程池中,所有任务存放到一个共享的 channel 中,由多个协程同时去消费 channel 中的任务,谁先拿到谁先执行。它的好处是下发任务的逻辑可以实现的很简单,拿到任务直接放到共享 channel 里即可。缺点是多个协程同时消费一个 channel 会涉及锁的争夺,当协程执行比较耗时的任务时,单个 channel 也容易带来容量问题。

调度式协程池中,每个协程都有自己的 channel,每个协程只消费自己的 channel。当下发任务的时候,可以采用负载均衡算法选择合适的协程来执行任务。比如选择排队中任务最少的协程,或者简单轮询。

17原文:
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=547#/detail/pc?id=5304

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值