18张图揭秘高性能Linux服务器内存池技术是如何实现的定制产品一般价格不菲,而这种定制产品注定不会受到大众的欢迎,所以定制产品就一个字,独一无二。可能有同学会有疑惑,你不是要讲技术吗?怎么又说消费了?原来技术也包括大众化产品和定制化产品。通用VS自定义作为程序员(C/C++),我们知道malloc是用来申请内存的。Malloc其实是一个通用的产品,可以在任何场景下使用,但是它可以在任何场景下使用。不会有高性能。malloc之所以性能不高,是因为它没有针对特定场景进行优化。另外,malloc看似简单,其实它的调用过程非常复杂。一个malloc调用过程可能需要操作系统的配合才能完成。.那么当malloc被调用时,底层会发生什么?简而言之,有几个典型的步骤:malloc开始寻找一个空闲的内存块,如果它能找到合适的大小,就分配它。如果malloc找不到合适的空闲内存块,则调用brk等系统调用扩大堆区以获得更多的空闲内存。malloc调用brk后,开始进入内核态。这时操作系统中的虚拟内存系统开始工作,扩大进程的堆区。注意附加扩展。一部分内存只是虚拟内存,操作系统并没有为其分配真正的物理内存。brk执行后返回malloc,从内核态切换到用户态,malloc找到合适的空闲内存返回。以上就是一个完整的内存应用。过程,我们可以看出一个内存申请过程其实是非常复杂的,可以参考这里对这个问题进行详细的讨论。由于每次分配内存都要经过如此复杂的过程,如果程序使用malloc大量申请内存,则注定程序无法实现高性能。幸运的是,除了流行的malloc,我们还可以对其进行自定义,即维护针对特定场景的内存申请和分配。这就是高性能高并发必备的内存池技术。内存池技术有什么特别之处吗?可能有同学会说,等等,这里说的malloc和内存池技术有什么区别?第一个区别是我们所说的malloc实际上是标准库的一部分,在标准库层面;内存池是应用程序的一部分。二是定位。我们自己实现的malloc其实是定位于通用性的。通用内存分配器的设计和实现往往比较复杂,但内存池技术不同。内存池技术专门针对特定场景进行优化。程序性能,但内存池技术的通用性很差。在一个场景下性能很高的内存池,在其他场景下基本没有办法做到高性能,甚至根本无法在其他场景下使用。是内存池技术的定位。那么内存池技术是如何优化性能的呢?内存池技术的原理很简单。内存池技术一次性获取一大块内存,然后在其之上管理内存的申请和释放,从而绕过了标准库。而操作系统:也就是说,通过内存池,一次内存申请不用再绕一大圈了。此外,我们还可以根据具体的使用模式进一步优化。例如,在服务器端,可能只有几种类型的对象需要为每个用户请求创建。那么我们就可以在自己的内存池中提前创建这些对象。对象,当业务逻辑需要时,从内存池中申请创建的对象,使用后返回内存池。因此,我们可以看到,这种为某些应用场景定制的内存池,比通用的malloc内存分配器有很大的优势。接下来我们着手实施一个。实现内存池的注意事项值得注意的是,实现内存池的方式其实有很多种。这里我们将以服务器端编程为例进行说明。假设你的服务器程序很简单,在处理用户请求的时候只用到一个对象(数据结构),那么最简单的就是我们提前申请一堆,用的时候取出一个,用完再归还:如何约,够简单!这样的内存池只能分配特定的对象(数据结构)。当然这样的内存池需要维护哪些对象已经分配了,哪些还没有使用。不过这里我们可以实现稍微复杂一点的,就是可以申请不同大小的内存,因为是服务器端编程,我们只在用户请求的过程中申请内存,当用户请求的时候才释放一次用户请求被处理。所有内存,从而将内存申请释放的开销降到最低。因此,可以看出内存池是针对特定场景设计的。现在有了初步的设计,接下来就是细节了。数据结构为了能够分配可变大小的对象,我们显然需要管理空闲内存块。我们可以用一个链表把所有内存块链接起来,然后用一个指针记录当前空闲内存块位置,如图:从图中我们可以看到有两个空闲内存块,空闲内存块内存由链表链接。每个内存块都是前一个的两倍大小,也就是当内存池中的空闲内存不够分配时我们从malloc申请内存,但是它的大小是前一个的两倍:其次,我们有一个指针free_ptr,指向下一个空闲内存块的起始位置。给内存池分配内存时,找到free_ptr,判断当前内存池中剩余空闲空间是否足够,如果有就分配并修改free_ptr,否则申请malloc翻倍内存再次。从这里的设计可以看出,我们的内存池其实并没有像free那样提供内存释放的功能。如果我们要释放内存,我们会一次性释放整个内存池。这类似于通用内存分配器。是不同的。现在,我们可以分配内存了,还有一个所有内存池设计都要考虑的问题,那就是线程安全,可以参考这里的这个话题。线程安全显然,内存池不应该仅限于单线程的场景,那么我们的内存池如何做到线程安全呢?有的同学可能会说,这还不简单,给内存池加把锁保护一下就好了。这种方法可行吗?还是那句话,Itdepends,视情况而定。如果你的程序有大量线程请求释放内存,那么这种方案下锁的竞争会非常激烈,在线程等场景下使用这种方案不会有很好的性能。那么有没有更好的办法呢?答案是肯定的。线程本地存储由于多线程使用线程池存在竞争问题,所以我们应该简单的为每个线程维护一个内存池,这样就不会出现多线程之间的竞争问题。那么我们如何为每个线程维护一个内存池呢?线程本地存储,ThreadLocalStorage就是用来解决这类问题的,什么是线程本地存储?简单的说,我们可以创建一个全局变量,这样所有的线程都可以使用这个全局变量,但是同时,我们把这个全局变量声明为线程私有存储,那么虽然所有的线程看起来还是使用同一个全局变量变量,但是全局变量在每个线程中都有自己的副本,变量指向的值是线程私有的,不会互相干扰。对于线程本地存储,你可以参考这里。假设全局变量是一个整数,变量名是global_value,初始值为100,那么当线程A修改global_value为200时,线程B看到的global_value的值还是100,只有线程看到的global_valueA是100,这是线程本地存储的作用。线程本地存储+内存池线程本地存储的问题很简单。我们可以把内存池声明为线程本地存储,这样每个线程只会操作自己的内存池,就不会再有锁竞争的问题了。注意,这里虽然给出了线程局部存储的设计,但并不代表加锁的方案就不如线程局部存储的方案。也就是说,一切都取决于使用场景。如果加锁的方案就足够了,那么我们就不需要绞尽脑汁去使用其他的方案了,因为加锁的方案更简单,代码也更容易维护。还需要提醒的是,这只是内存池的一种实现方法。并不是说所有的内存池都应该这样设计。内存池可以很简单也可以很复杂。一切以实际场景为准,这也是需要注意的。其他内存池形式至此我们给出了两种内存池设计方法。首先是预先创建一堆需要的对象(数据结构),维护哪些对象(数据结构)可用,哪些已经分配。;第二种可以申请任意大小的内存空间,使用时只申请不释放,最后一次性释放。这两个内存池天然适合服务端编程。最后介绍一种内存池实现技术。这个内存池会预先申请一大段内存,然后把这大段内存分成大小相同的小内存块:然后我们自己维护这些分区。哪些小内存块是空闲的,哪些已经分配了。例如,我们可以利用栈的数据结构,将所有空闲内存块的地址一开始就压入栈中。当分配内存时,会弹出一个。用户使用完之后再推回栈。从这里的设计我们可以看出这个内存池是有限制的。这个限制是指程序申请的最大内存不能超过这里内存块的大小,否则将不足以容纳用户数据。您需要非常了解业务。用户申请内存后,可以根据需要整形为具体的对象(数据结构)。关于线程安全的问题,也可以使用线程局部存储来实现:一个有趣的问题除了线程安全,这里还有一个很有意思的问题,就是如果线程A请求的对象被线程释放了B、我们如何处理内存池?这个问题很有趣,因为我们必须知道内存属于哪个线程的本地存储,但分配的内存本身并没有告诉你。可能有同学会说,这不简单,不就是一个指针到另一个指针的映射吗,直接用一个map存储就可以了,但是问题并没有这么简单,原因是如果我们划分内存如果block小,内存块就会很多,需要存储大量的映射关系。有什么办法可以改善吗?改进方法如下。一般来说,我们申请的大段内存,其实都会按照一个特定的大小进行对齐。我们假设总是按照4K字节对齐,那么大段内存(4K=2^12)起始地址的后12位永远为0,比如地址0x9abcd000,同时我们也假设申请的大内存段大小也是4K:那么我们就可以知道大内存块中每个小内存块的起始地址,除了后面的12位都是一样的:这样,我们就可以知道起始地址了当我们得到任何内存的地址时,对应的大内存段。我们只需要简单地将最后12位设置为0,我们就拥有了一个大段内存的其余起始地址就简单了。我们可以在大段内存的末尾保存相应的线程本地存储信息:这样我们就可以对任意内存块地址进行简单的位操作,得到相应的线程本地存储信息,大大降低了维护映射信息的内存占用.总结内存池是高性能服务器中常用的优化技术。这里我们介绍三种实现方法。值得注意的是,内存池的实现并没有统一的标准,一切都要根据具体的场景进行定制,所以我们可以看出内存池设计是有针对性的,当然它的对立面是不通用的。希望这篇文章能帮助您了解内存池。本文转载自微信公众号《码农的荒岛求生》,可通过以下二维码关注。转载本文请联系码农荒岛求生公众号。
