常见池化技术:连接池、线程池和内存池等

这里主要说几种:连接池、线程池、内存池和异步请求池,下面依次做具体的介绍。

连接池

连接池,如客户端连接请求redis、mysql等,目的是为了可以做并发操作,以及对连接的复用,一般和多线程一起使用,使得每个线程可以从连接池中获取一个连接进行请求,这样是最合适的,也就是说线程的个数和连接池中连接的个数保持差不多的大小最好,如果有8个线程,而连接池只有4个连接可以取来使用,那么当有4个线程取走连接后,另外的4个线程就从连接池中取不到连接了,此时就需要等待那4个正在使用的线程归还连接才能继续,这样就导致降低了并行的能力。

从上面的描述中也可以看出,多个线程从连接池中取连接,所以连接池本身是一个临界资源,使用过程中需要加锁,连接池初始化时的连接数不会设到最大,会有一个最低连接数,当比较多的线程需要获取连接时,再自动增加新的连接,当连接池连接数已经达到最大的时候,再有线程过来获取连接就需要等待了,可以根据业务场景设置为一直等待或者超时等待(使用wait_for),直到有其他的正在使用连接的线程归还连接后,就可以唤醒正在等待的条件变量,此时就可以获取到连接了,所以一直等待也不是永久等待,除非其他获取了连接的线程一直不归还了,那么其实是有bug的,当然还有一种情况需要能够退出,就是当连接池销毁时,还在等待获取连接的地方需要被唤醒并退出。

这里面有一些可以优化的地方,比如可以将一分钟内超时等待的次数统计出来,以此来评定连接池的是否需要扩容,另外,有的连接可能被某个使用的线程长时间阻塞,导致无法归还,其他的线程就没有足够多的连接可以获取了,这是有问题的,所以可以在取到连接时将该连接加入到used_list中,并在连接中记录当前使用时间,如果后面连接归还了,再从used_list中删除,另起一个线程定时检测used_list,看有没有超时未归还的连接,有的话把信息进行告警。还有一个点,当连接池长时间空闲,没有那么多线程需要获取连接时,可以销毁掉长时间不活跃的连接,但连接池需要保持有最低数量的连接数,不能全部销毁掉,最后,连接可以做自动重连处理,如果发现连接断掉了,能够自动重连,如mysql可以设置MYSQL_OPT_RECONNCT,进行请求前先做mysql_ping操作。

线程池

线程池部分,一般服务端程序在进行数据解析、数据计算时,可以将这些解析和计算任务丢进线程池中进行执行,这里主要有三个部分组成,一个是任务队列,需要执行的任务先放到这里,可以看作是一个生产者,一个是执行队列,也就是线程池真正执行任务的地方,可以看作是消费者,而生产者消费者直接需要配合,比如没有任务了的时候,消费者需要阻塞等待,有任务来的时候,需要通知消费者去取任务执行,所以还需要一个管理模块,主要是mutex锁和cond条件变量。线程池中的线程其实是一个循环取任务执行的操作,取到任务就执行,没有任务了就等待,取到的任务里面有任务的回调函数,直接执行回调函数及其参数就相当于执行任务了,当然,如果整个线程池需要退出的时候,就不要再等待了,也退出循环即可,不建议使用pthread_cancle的方式退出线程,而是用条件变量唤醒等待的线程,并内部自动退出的方式,这才是优雅的退出方式,这才是可控的,知道线程内部在干嘛,直接pthread_cancle就比较暴力。

内存池

内存池部分,为什么要使用内存池?主要是为了避免内部内存碎片,即分配出去的内存片段被回收后无法再次分配的情况,比如0-145字节,159-255字节已经使用了,中间的146-158是释放出来的空间,共12字节,但后续需要分配200字节的大小,只能从256字节的位置分配,中间的12字节就利用不上了,所以应用程序这个区域的内存就无法及时的得到利用了,这里的内存都是指的是虚拟内存。

这里有个题外话题需要注意,应用程序分配的都是虚拟内存区域vma,并不一定是一块连续的物理内存地址,32位的系统的虚拟内存为0-3GB的虚拟区域(和物理地址的映射关系有页目录pgd决定),内核使用的是3GB-4GB的区域(和物理地址是线性映射,减去PAGE_OFFSET即可),一个应用程序对应内核里面的一个task_struct变量,该变量其中有一个mm_struct,它包括多个vma虚拟内存区域vm_area_struct,这些vma可以在/proc/pid/maps看到,这些vma可分为代码段、数据段、bss、堆、动态库共享内存、栈,vma里面有一个pgd变量,决定这块vma是怎样和物理内存建立映射关系的。我们在进程里面用clone创建一个线程时,实际调用的是fork,只是和进程共用了同一个mm_struct,另外mmap是vma直接和设备物理地址建立了映射关系,有数据后应用程序可以直接读到,因为数据已经在应用程序内存空间了,不像read是先读到内核中,应用程序再去读,比read这种mmap效率就提高了,不过需要设备驱动本身支持mmap,驱动需要给出vma到物理地址的映射关系,内核也给了接口供取驱动程序调用,参考remap_pfn_range函数。

其实还有一个问题,我们分配内存调用的是malloc库函数,既然有内存碎片问题,那这个库函数为啥不解决呢?如果你也有这个问题,说明你对这个内存碎片还没有完全理解,因为你需要申请大于内存碎片大小的内存,所以只能从虚拟内存区域未分配的区域开始分配了,这个对malloc也是无解的。倒是有一些可以了解的是,glibc的malloc其实是ptmalloc,它也不是每次都调用系统调用让操作系统给你分配内存,如果分配几个字节也让操作系统去处理,那效率就低了,一般是先申请比较大的内存,以页为单位的那种,再从这个大内存块里面分配应用程序需要大小的内存给应用程序,至于它是怎么管理这个大块内存的,那就是这个ptmalloc做的事情了,其实tcmalloc、jemalloc做的都是这样的事情,只是分配效率的高低而已。

有什么办法解决程序内部碎片的问题呢?假设有个4k大小的内存,要怎么分配,才能让回收的时候不会有内存碎片产生?这里有个规律,4k分配掉1个字节后,剩下还有2k+1k+512+256+128+64+32+16+8+4+2+1,后面可以按照这些大小进行分配,比如分配250字节时,直接分配256字节,还需要再分配200字节时,将512直接均分,分配其中的256字节,剩下的256字节保留下来,后面该分配的256字节释放后,再和原来的256自己合并,变成512字节,这样后面就又有大内存可以分配了,这里合并有几个条件,首先大小要相等,然后内存地址要相邻,这里的内存地址还是指的是虚拟内存地址,其实这就类似于内核中的伙伴算法思想,它是以牺牲多分配一部分内存为代价的,所以这种方案不可取,多牺牲的分配内存和内存碎片是一样的,利用不上,但在内核中是可以使用伙伴算法的,内核分配是以也为单位,即4k,多分配的内存称为外部碎片,比如应用程序需要申请7k,但是内核分配是4k为单位,所以会分配8k,注意这8k是连续的物理空间,相当于多分配了1k,但这个多分配的1k会被ptmalloc算法接管,后续应用程序需要1k时就可以利用上了,所以内核可以使用伙伴算法。

那么还有其余办法避免内部内存泄露吗?可以先申请页框大小的内存块,将其分成若干个n字节大小的小块,n为某个结构体变量的大小,那么当要为该结构体分配内存时,直接从中取一块就好了,这样释放后也不会造成内存泄露,下次再分配该变量的内存时就可以使用上了,如果还有其他大小m的结构体 同样也是先申请大块内存,将其分为很多个m大小的小块内存,以供后面的申请需求,这种思想就是内核里面的slab算法,内核里有很多k_mem_cache,称为高速缓存,每个cache下有多个slab,slab是有多个连续page页框组成,这些page是通过伙伴算法申请的,page里装有很多相同大小的object,如果这个slab的objects全被使用了,则将该slab移到cache里的slabs_full链表中,如果只用了一些,则放在slabs_partial中,否则放在slabs_empty链表中,内核里面的kmalloc就是使用slab算法分配内存的,可以看出slab算法是依赖伙伴算法的,主要用于分配小块内存,解决内部内存碎片问题,可以把内核看成一个应用程序,slab思想可以解决内部内存碎片问题,那我们自己上层应用程序也可以使用slab算法解决内部内存碎片问题。

针对应用程序特定的场景,比如在一个http连接中分配内存,连接完成后释放内存,这种场景可以考虑比较特殊的方式来避免内存泄露,可以只考虑申请内存,在连接结束后再整体释放该连接申请的内存,nginx里面就是用的这种内存池,该连接池具体可以这么设计,包括两个部分,当需要申请的内存大小小于4k时,直接从预先申请好的4k空间里面申请,在这4k空间里标记好已经申请到了哪个位置current->last,如果4k的内存都被分配完了,即到达了current->end位置,则再重新向操作系统申请一块4k的内存空间,将current指向重新申请的这块内存,如果4k里剩余的空间不足以进行分配了,也是需要申请一个新的4k空间,只是current指针还是指在原来的地方,后续分配内存时还是从current所在的4k空间开始遍历,分配不足则切换到next的4k空间,如果next为空了,则需要申请4k的空间来进行分配,这里有个参数,当某个4k空间的剩余区域超过4次不足以分配了时,直接将current指向next了,即不再去遍历那一个4k块了,剩余那点未分配的就不要了,可以将这种4k的空间称为mp_node_s结构,另一部分,如果申请的空间比较大,大于4k,则直接向操作系统申请,将申请到的空间的地址记录下来,放到mp_large_s链表里,以便后续释放。总的来说,mp_pool主要由这三个部分组成:mp_node_s head[0]、mp_node_s current和mp_large_s large,head[0]是个柔性数组,并不占空间,需要放在结构体最后,最开始的current是指向head的,分配mp_large_s结构体时也要从4k大小里面分配响相应的大小来,这样就不会有内存碎片了,后续小内存找current分配,大内存向操作系统申请后挂到large链表。有个需要注意的点,这些mp_node_s、mp_large_s甚至mp_pool结构体的内存分配也要分配到内存池里,不能有任何内存碎片,所以第一次应该申请sizeof(mp_pool)+sizeof(mp_node_s)+4k的大小,另外,考虑到内存对齐,申请4k块内存要用posix_memalign函数,以及在4k块中分配内存时也要考虑地址对齐问题,平常用的malloc分配的内存都是有默认对齐的。这里注意,最后释放内存池的时候,需要先是否large链表,如果把mp_pool先释放了,那large就找不到了 因为large是mp_pool的成员。

异步请求池

最后再讲异步请求池的问题,这是一个什么样的使用场景呢?当我们需要通过for循环类似的多次向服务端请求时,比如sql查询,dns查询等,每次请问都是一个连接去处理的,如果采取同步的方式处理,那么需要等待当前请求返回后,才能开启下一个请求,如果采用异步的请求,那么只要请求发出去了,就可以继续发下一个请求,不必要等待上一个请求的response,这样就相当于并行发送请求了,效率上就高一些,但这个不要和连接池混淆,连接池需要和多线程使用才能并发,连接池主要是可以减少重复建连的时间。

那这个异步请求池需要如何实现呢?主要包括几个部分,需要建立一个epollfd和thread,因为需要对发送请求的连接进行io管理,线程里面就是一个循环,不断的epoll_wait,看请求的fd有没有respose回来,有的话就调fd事件的回调函数进行处理,这里的epoll就可以采用reactor的模型来实现,这里的回调函数就是在发送请求的时候将请求的fd以及其回调函数添加到epoll里面去的,所以其实流程很清晰,发送请求是加入fd进行多路复用管理,然后等待读事件,也就是等待response的到来,最后读到response后调用回调函数进行处理,可以看出,发送请求和处理response并不是在同一个线程处理的,另外,如果有的请求发出去后,服务端长时间没有响应,那么就需要管理好这个fd,以防后续没有释放。

通篇不知不觉讲了几千字了,主要是几种池式结构,这些都是基本功,掌握了后就一通百通了,在脑海里面有个印象,模糊了的时候再翻一翻,能用自己的话给别人讲明白,就算学到了一点,否则,知识都是一知半解,问深一点就不知道了。

nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!