文章目录
- 线程的内存结构
- 栈帧
- 线程/进程调度
- 线程的进一步使用
- 线程安全和可重入
- 一般的内存使用
- static变量
- 使用new关键字,访问堆上的内存
- 类中的内存使用
从上一篇文章来看,线程的使用是比较简单的。但是在c++环境下使用线程,最难也是最麻烦的点在于对内存的管理。因为如果多线程独立运行的话(不是一个一个的join,串行执行),那么对于一些公共的指针,也就是说在不同线程中指向相同位置的内存区域的开辟与释放,就是非常麻烦的事情。因为你不知道哪个线程会先执行到这个位置,把内存开辟了(顺序乱了的话就容易造成野指针,造成内存泄漏)。如果是释放顺序错了的话,就会导致非法访问已经释放的内存区域,造成内存越界,程序直接core dump。
所以这一篇打算说一说线程和内存的关系。
线程的内存结构
首先,线程本身也是一种数据结构,这个数据结构中保持的数据,很多都是用于操作系统用于做线程调度使用的,当然也还有一些是其他的作用,这一小节我们就先来看一下线程这个实体(entity)在程序运行时,在内存中的结构。
std C++11标准库中的thread也需要基于具体的平台的实现,不同平台的实现都会不一样。这里只提一些通用的部分。因为大部分程序还是在linux下开发,所以更多的源代码部分都是基于linux操作系统的。基本上都是基于POSIX标准。
栈帧
我在之前的一篇关于内存的文章中提到过C++程序运行时在内存中的结构:
https://blog.csdn.net/pcgamer/article/details/128148962?spm=1001.2014.3001.5501
其中有一个区域叫做“栈区”,线程的数据就保存在这个区域。
首先需要理解一下,所有的线程运行都是执行一行一行的代码,或者说可以执行一个一个的函数。这些函数和代码都是保存在内存的代码区域的。
或者说函数是一个静态的概念,而线程是一个动态的概念。函数代码通过线程这个动态实体,被操作系统调度到cpu上运行。
所以,首先看一下一个函数被调用时在内存中的结构(不管是单线程还是多线程,都会调用到函数)
具体的结构,有兴趣的朋友可以去翻翻源码。
函数调用的过程可以概括为:
- 操作系统创建一个栈帧结构
- 将返回地址赋值到栈帧结构中(当前函数的下一条代码地址,代码区域中的地址,用于函数返回用)
- 将行参从右往左的顺序入栈(C代码的标准,其他的不一定,有些地方使用__stdcall这个宏定义就是干的这个)
- 为其他变量什么的赋值
- 将栈帧入栈
那么函数结束返回的过程可以概括为:
- 将函数的返回值保存在eax寄存器中
- 将当前栈帧出栈,从栈中取到返回地址,并跳转到该位置
- 将当前栈顶的地址给esp寄存器中,就是下一个栈帧。
从上面的描述来看,我们在一个函数中定义的局部变量(指针除外)是跟随着该函数的栈帧创建而创建;函数执行完毕后,栈帧出栈被清除也就被清除了。
但是如果是函数本地变量的指针,如果使用了new等操作符分配了堆上的内存的话,该指针随着函数结束被清除,但是指针指向的地址是不会被释放的,就会造成内存泄漏。
从多线程的角度来看,可以知道:
- 函数中的本地变量(非指针)是安全的,不会因为多线程的调用而冲突,所以上一篇中讲到的创建线程的传参方式是值传递,就是保证了这个。
- 如果在函数中使用了指针,那么就要悠着点,因为你不知道你哪个线程先被调用,如果提前释放了,就会导致错误。
- 如果是静态变量,那么会发生不一致的情况发生,同样是因为线程的调度问题。
所以,确定线程的调度逻辑,是用好多线程的一个基础。
线程/进程调度
前面说到,线程实际上就是把一个一个的函数代码放到cpu上去运行,那么可以理解为线程就是函数被操作系统调度的一个实体。
大概是下图这个关系:
- 操作系统中有一个任务调度的实体,在linux上的结构为task_struct,这个结构维护了一个调度线程的队列及其相关信息,比如线程的状态,内存地址等信息。
- 当一个函数被调用时(其实就可以是一个线程,因为函数被调用,肯定是一个线程这样一个动态的概念才能被调用),操作系统创建一个栈帧,把函数的地址,行参等信息入栈,创建局部变量入栈等操作。
- ESP寄存器,这个寄存器一般都是用于记录上一次栈帧的地址,也就是上一次函数的返回位置。当调用一个新函数时,把这个ESP中的地址记录到task结构中,然后把最新的栈帧地址填入到ESP中。
- 当函数执行完成后,ESP将上一步记录到task结构中的地址拿出来填上,再把当前栈区空间中的栈顶的栈帧退栈,即可完成函数的调用和返回(当然还有一些其他的清理工作)
线程的进一步使用
线程安全和可重入
线程的使用会涉及到两个概念:线程安全和可重入。
这两个概念有很多中解释法,可重入(reentrancy)在wiki上还能找到一个词条: In computing, a computer program or subroutine is called reentrant if it can be interrupted in the middle of its execution and then safely called again (“re-entered”) before its previous invocations
complete execution. The interruption could be caused by an internal action such as a jump or call, or by an external action such as a hardware interrupt or signal. Once the reentered invocation completes, the previous invocations will resume correct execution.
简单来说,就是一个线程执行这个方法时,被中断程序打断后,重新恢复运行时不出错。
而线程安全的含义一般指的是,多个线程在调用同一段代码的时候,所有的线程都可以得到正确的结果。
从上面两个描述上来看,个人觉得两个概念基本上差不多,本质上都是对内存中的变量进行访问时不出问题,能得到预期的结果。
所以这里重点说说多线程中内存的使用。
一般的内存使用
从上面栈帧和调度的分析来看,如果一个函数使用的都是局部变量,那么在多线程中肯定是安全的,因为这些变量的内存都是随着线程的调度,而在栈帧中统一被调入和调出。调出后其他的线程也是无法访问和修改的,所以肯定是安全的,也是能得到预期的结果的。
但是,绝大部分场景下,多个线程的合作肯定是会要访问同一个变量或者结构,也就是访问同一块内存的,不考虑类的情况下,有如下的几种情况:
static变量
static变量为在全局数据区分配内存,所有的线程都可以访问到,所以如果要保证得到预期结果,必须根据业务需求在变量的修改上增加锁控制。
使用new关键字,访问堆上的内存
-
如果在多线程函数内部使用new函数。用于保存内存地址的指针需要时局部变量:
void pFunc() { char * cPtr = new char[1024]; // do something delete cPtr; }
这样的话,虽然内存是在堆上,但是这个指针是跟随着栈帧走的,不会存在被其他线程修改的情况。
有一个风险就在于在执行到一半的时候被父线程或者其他原因kill掉,导致内存泄漏。如果cPtr是一个static的话,那么就很有可能在一个的线程中被delete后,再由某一个线程进行访问,在多线程下就是不安全的。
-
如果是通过函数的行参传入到函数内,这种情况多见于回调函数的使用中。
因为回调函数很多情况下是由其他函数调用回来的,而且一般来说都是由事件驱动,基于多线程的。
在这种情况下,一般来说遵守一个原则:指针参数变量最好是设置为const变量。void pFunc(const char * cPtr) { // do something }
因为在这种情况下,这个指针肯定是由外部调用来创建和初始化的,根据谁创建,谁修改,谁销毁的原则。在多线程函数内部,最好不要对这个指针指向的内存做修改和销毁。
另外,如果要使用的话,尽量在函数的的最开始就单独创建一个属于线程函数自身的内存,并将数据拷贝过来进行使用。void pFunc(const char * cPtr, int size) { char * _cPtr = new char[size]; memset(_cPtr, 0, size); memcpy(_cPtr, cPtr, size); // do something delete [] _cPtr; ... }
类中的内存使用
如果使用到类,和纯面向过程的代码有一点区别:
- 首先类中的方法来说,在前面的一篇文章提到过,类的方法实际上在符号表中也是会形成一个和普通方法相同的符号,只不过是根据类名,行参等名字做了一些前缀和后缀,所以线程调用起来和普通的方法是一样的,也是通过栈帧的方式来传递。
至于方法中的局部变量什么的就和上面提到的是一样的了。 - 类中的成员静态成员变量:所有类对象共有,位于内存的静态区,和上面的静态变量使用方式类似。
- 类普通成员变量(非指针,指针的要参考具体是怎么创建的,如果是new的话,就都是在堆上)。成员变量的话是在类对象创建和初始化的时候来确定位置的。那么:
- 如果类对象是通过new的方式来生成的,那么所有的类成员都一起在堆上生成。
- 如果类对象是作为函数的局部变量来声明的,那就是在栈区,也就是跟随着这个函数的栈帧一起被调度。