从线程原理的角度来看C++内存的使用

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

文章目录


从上一篇文章来看线程的使用是比较简单的。但是在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的方式来生成的那么所有的类成员都一起在堆上生成。
    • 如果类对象是作为函数的局部变量来声明的那就是在栈区也就是跟随着这个函数的栈帧一起被调度。
阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: c++