18 张图揭秘高性能 Linux 服务器内存池技术是如何实现的
malloc 开始搜索空闲内存块,如果能找到一块大小合适的就分配出去
如果 malloc 找不到一块合适的空闲内存,那么调用 brk 等系统调用扩大堆区从而获得更多的空闲内存
malloc 调用 brk 后开始转入内核态,此时操作系统中的虚拟内存系统开始工作,扩大进程的堆区,注意额外扩大的这一部分内存仅仅是虚拟内存,操作系统并没有为此分配真正的物理内存
brk 执行结束后返回到 malloc,从内核态切换到用户态,malloc 找到一块合适的空闲内存后返回
以上就是一次内存申请的完整过程,我们可以看到,一次内存申请过程其实是非常复杂的,关于这个问题的详细讨论你可以参考这里。既然每次分配内存都要经过这么复杂的过程,那么如果程序大量使用 malloc 申请内存那么该程序注定无法获得高性能。幸好,除了大众货的 malloc,我们还可以私人定制,也就是针对特定场景自己来维护内存申请和分配,这就是高性能高并发必备的内存池技术。
内存池技术有什么特殊的吗?
=============
有的同学可能会说,等等,那 malloc 和这里提到的内存池技术有什么区别呢?
第一个区别在于我们所说的 malloc 其实是标准库的一部分,位于标准库这一层;而内存池是应用程序的一部分。
其次在于定位,我们自己实现的 malloc 其实也是定位通用性的,通用性的内存分配器设计实现往往比较复杂,但是内存池技术就不一样了,内存池技术专用于某个特定场景,以此优化程序性能,但内存池技术的通用性是很差的,在一种场景下有很高性能的内存池基本上没有办法在其它场景也能获得高性能,甚至根本就不能用于其它场景,这就是内存池这种技术的定位。
那么内存池技术是怎样优化性能的呢?
内存池技术原理
=======
简单来说,内存池技术一次性获取到大块内存,然后在其之上自己管理内存的申请和释放,这样就绕过了标准库以及操作系统:
也就是说,通过内存池,一次内存的申请再也不用去绕一大圈了。除此之外,我们可以根据特定的使用模式来进一步优化,比如在服务器端,每次用户请求需要创建的对象可能就那几种,那么这时我们就可以在自己的内存池上提前创建出这些对象,当业务逻辑需要时就从内存池中申请已经创建好的对象,使用完毕后还回内存池。因此我们可以看到,这种为某些应用场景定制的内存池相比通用的比如 malloc 内存分配器会有大的优势。接下来我们就着手实现一个。
实现内存池的考虑
====
====
值得注意的是,内存池实际上有很多的实现方法,在这里我们还是以服务器端编程为例来说明。假设你的服务器程序非常简单,处理用户请求时只使用一种对象(数据结构),那么最简单的就是我们提前申请出一堆来,使用的时候拿出一个,使用完后还回去:
怎么样,足够简单吧!这样的内存池只能分配特定对象(数据结构),当然这样的内存池需要自己维护哪些对象是已经被分配出去的,哪些是还没有被使用的。但是,在这里我们可以实现一个稍微复杂一些的,那就是可以申请不同大小的内存,而且由于是服务器端编程,那么一次用户请求过程中我们只申请内存,只有当用户请求处理完毕后一次性释放所有内存,从而将内存申请释放的开销降低到最小。因此,你可以看到,内存池的设计都是针对特定场景的。现在,有了初步的设计,接下来就是细节了。
数据结构
====
为了能够分配大小可变的对象,显然我们需要管理空闲内存块,我们可以用一个链表把所有内存块链接起来,然后使用一个指针来记录当前空闲内存块的位置,如图所示:
从图中我们可以看到,有两个空闲内存块,空闲内存之间使用链表链接起来,每个内存块都是前一个的 2 倍,也就是说,当内存池中的空闲内存不足以分配时我们就向 malloc 申请内存,只不过其大小是前一个的 2 倍:
其次,我们有一个指针 free_ptr,指向接下来的空闲内存块起始位置,当向内存池分配内存时找到 free_ptr 并判断当前内存池剩余空闲是否足够就可以了,有就分配出去并修改 free_ptr,否则向 malloc 再次成倍申请内存。从这里的设计可以看出,我们的内存池其实是不会提供类似 free 这样的内存释放函数的,如果要释放内存,那么会一次性将整个内存池释放掉,这一点和通用的内存分配器是不一样。现在,我们可以分配内存了,还有一个问题是所有内存池设计不得不考虑的,那就是线程安全,这个话题你可以参考这里。
线程安全
====
显然,内存池不应该局限在单线程场景,那我们的内存池要怎样实现线程安全呢?有的同学可能会说这还不简单,直接给内存池一把锁保护就可以了。
这种方法是不是可行呢?还是那句话,It depends,要看情况。
如果你的程序有大量线程申请释放内存,那么这种方案下锁的竞争将会非常激烈,线程这样的场景下使用该方案不会有很好的性能。
那么还有没有一种更好的办法吗?答案是肯定的。
线程局部存储
======
既然多线程使用线程池存在竞争问题,那么干脆我们为每个线程维护一个内存池就好了,这样多线程间就不存在竞争问题了。
那么我们该怎样为每个线程维护一个内存池呢?
线程局部存储,Thread Local Storage 正是用于解决这一类问题的,什么是线程局部存储呢?
简单说就是,我们可以创建一个全局变量,因此所有线程都可以使用该全局变量,但与此同时,我们将该全局变量声明为线程私有存储,那么这时虽然所有线程依然看似使用同一个全局变量,但该全局变量在每个线程中都有自己的副本,变量指向的值是线程私有的,相互之间不会干扰。
关于线程局部存储,可以参考这里。假设这个全局变量是一个整数,变量名字为 global_value,初始值为 100,那么当线程 A 将 global_value 修改为 200 时,线程 B 看到的 global_value 的值依然为 100,只有线程 A 看到的 global_value 为 100,这就是线程局部存储的作用。
线程局部存储+内存池
==========
有了线程局部存储问题就简单了,我们可以将内存池声明为线程局部存储,这样每个线程都只会操作属于自己的内存池,这样就再也不会有锁竞争问题了。
注意,虽然这里给出了线程局部存储的设计,但并不是说加锁的方案就比不上线程局部存储方案,还是那句话,一切要看使用场景,如果加锁的方案够用,那么我们就没有必要绞尽脑汁的去用其它方案,因为加锁的方案更简单,代码也更容易维护。还需要提醒的是,这里只是给出了内存池的一种实现方法,并不是说所有内存池都要这么设计,内存池可以简单也可复杂,一切要看实际场景,这一点也需要注意。
其它内存池形式
=======
到目前为止我们给出了两种内存池的设计方法,第一种是提前创建出一堆需要的对象(数据结构),自己维护好哪些对象(数据结构)可用哪些已被分配;第二种可以申请任意大小的内存空间,使用过程中只申请不释放,最后一次性释放。这两种内存池天然适用于服务器端编程。最后我们再来介绍一种内存池实现技术,这种内存池会提前申请出一大段内存,然后将这一大段内存切分为大小相同的小内存块:
然后我们自己来维护这些被切分出来的小内存块哪些是空闲的哪些是已经被分配的,比如我们可以使用栈这种数据结构,最初把所有空闲内存块地址 push 到栈中,分配内存是就 pop 出来一个,用户使用完毕后再 push 回栈里。
评论