在C++多线程中,static变量是不安全的。如果不想每次都分配一块内存,栈上又分配不了这么大的内存,那thread local是比较合适的选择。于是我的模块中是这么写的:
struct ThreadBuffer
{
ThreadBuffer()
{
buffer_ = new char[102400];
}
~ThreadBuffer
{
delete[] buffer_;
}
char *buffer_;
};
// 直接在log.cpp中声明一个生命周期为整个编译单元的thread local变量
thread_local ThreadBuffer tb;
这看起来是没什么问题的,然而我的程序是有内存泄漏检测机制的,偶尔会提示内存未释放。在这之前是不会出现的,排查了最近改动的代码没有找到问题。最终通过功能排查,定位到同时执行两个网络模块的单元测试,就会出现内存未释放的问题。而它俩分别单独执行时是不会出现的,但从代码逻辑上排查,又没有发现问题。
最终我不得不启用VLD(Visual Leak Detector),它确实准确检测出内存未释放的地方,从堆栈上看,就是上面的代码中ThreadBuffer构造函数申请的内存。
但这让我更懵逼了,就这几行代码,我左看右看都是有释放的啊。
知道问题,接下来我开始断点
发现有很多线程初始化了这个ThreadLocal变量,这些线程都不是我创建的,而是ntdll.dll或者mswsocket.dll创建的,甚至有个QQ五笔输入法的也来凑热闹。
而在释放的时候,通过断点发现大部分线程都是有释放thread_local变量的,但有几个ntdll.dll创建的线程,是没有调用thread_local的析构函数的。
泄漏的内存就是因为这些线程初始化了thread_local变量,但却没有释放它。
windows系统与linux系统的底层不一样。在linux系统,程序创建多少个线程,那这个进程就有多少个进程。而在windows,当程序调用线程或者网络相关的函数时,便会主动创建出一些系统所需要的ntdll.dll、mswsock.dll等线程来执行一些系统相关的逻辑。而这这些线程,由于各种实现方式不一样,的确会出现不调用thread_local析构函数的情况,比如: https://stackoverflow.com/questions/50897768/in-visual-studio-thread-local-variables-destructor-not-called-when-used-with
知道了问题,解决的办法也很简单。上面的写法里,由于thread_local变量的生命周期是整个编译单元(cpp文件),所以线程在创建时就会初始化。但实际那些系统线程,它不会执行你写的代码逻辑,是永远不需要这个变量的,改成局部变量就行。
ThreadBuffer &get_thread_buffer()
{
thread_local ThreadBuffer tb;
return &tb;
}
每次通过get函数获取即可,由于系统线程不会调用这个get函数,所以不会分配内存,也就不存在泄漏的问题。
TLS(thread local storage)变量有两种初始化,一种是全局(或者编译单元)的,在线程创建时就会出初始化,一种是局部的,程序第一次运行到声明thread_local变量时初始化。从这个问题我们知道要尽量避免声明全局或者编译单元级别的thread_local变量,因为一个线程不管是否用到这些tls变量,它都会初始化tls变量的。想象一下,某个模块声明了一个1M的tls变量,而其他模块创建了1000条线程,因为两个模块是不耦合的,这些线程是完全不会使用那些tls变量,但却会占用1000M的内存,那不是就白白浪费了。
另外,tls不在程序的bss或者data段,也不在堆上,而是在每个线程的tls存储区。这个tls存储区是有大小限制的(windows大约64kb,linux大约16kb),部分线程库的实现可能还有key数量的限制。这个模块用了,另一个模块可用的key数量和内存就少了。所以尽量使用局部threa local变量,避免浪费。同时也应该避免直接声明thread_local char[10240]这种巨大的tls变量,而是像上面的ThreadBuffer那样通过一个指针包装一下,在堆上申请内存。