C++类成员函数在多线程中的一些问题研究

之前只在C语言下,利用pthread搞过一些小demo玩。看实验室并行框架源码时发觉,全是C++风格的多线程,涉及到的pthread_create()为何不能以成员函数作为参数、锁成员变量、线程安全的类各种的问题,这些之前完全没有接触过。特此进行了两天的调研和思考,现在就简单整理一下这两天看过的资料。

由于就真的是光看没练,只是单纯翻了一堆大致原理,可能会有不少纰漏。阅读后觉得有不妥的地方,那很可能就真的是不妥的,笑。

1. C++类成员函数在内存中的摆法

之前转载了一篇文章,文中大致介绍C++类成员函数在内存中是怎么摆的。其实摆法和C语言就没什么区别,函数还是放在代码段,数据则是放在数据段+bss段+栈+堆中。
类成员函数,背后的原理大致上就相当于在函数调用中,自动多加了一个相关结构指针的参数,然后就可以访问到相关结构的成员变量。
多个对象及其成员函数的内存分布:
C++类成员函数在多线程中的一些问题研究_第1张图片
大致原理示例代码(当然,实际区别在于调用约定上):

#include 

using namespace std;

struct testStruct
{
    int i;
    char c;
};


void testFunc(testStruct* myThis)
{
    cout << "i: " << myThis->i << " c: " << myThis->c << endl;
}

int main()
{
    testStruct ts0 = {1, 'a'};
    testStruct ts1 = {2, 'b'};

    testFunc(&ts0);
    testFunc(&ts1);
}

2. pthread_create()为什么不能传成员函数

原因就是上面提到的调用约定,pthread_create()的参数需要是cdecl调用约定的函数。
调用约定大致上就是,发生函数调用时,在汇编层面到底是如何运作的。在gcc下,C++比较基本的调用约定有两种:

  • cdecl,C语言的基本调用约定,参数首先由有向左压入堆栈。所不同的是,函数本身不清理堆栈,调用者负责清理堆栈。由于这种变化,C调用约定允许函数的参数的个数是不固定的;
  • thiscall,对于gcc编译器,thiscall几乎与cdecl等同:调用者清理堆栈,参数从右到左传递。差别在于this指针,thiscall会在最后把this指针推入栈中,即相当于在函数原型中是隐式的左数第一个参数;

所以调用约定基本上要解决这三个问题:

  • 当参数个数多于一个时,按照什么顺序把参数压入堆栈;
  • 函数调用后,由谁来把堆栈恢复原状;
  • 函数的返回值放在什么地方;

所以在gcc的thiscall调用约定下,类成员函数真的就是弄了个隐藏this指针参数。

3. 互斥锁成员变量和线程安全的类

互斥锁成员变量

由于之前只是简单地用写了多线程的demo,互斥锁都是直接定义成全局变量,加上之前对于成员函数在内存怎么摆也有疑惑,所以就搞的很乱。
其实类中定义互斥锁,和全局定义互斥锁,差别并不大。
明确以下这些点,我的疑惑就消除了:

  • 互斥锁其实和普通变量没分别,多个线程访问同一个函数时,使用的互斥锁相同,才会被同步;
  • 全局和静态变量,栈变量(先不谈堆,简化论述)的生命周期问题;
  • 全局和静态锁也好,栈上的锁也好,只要生命周期没完结,就能锁的住;

点1:----------------------------------------------------------------

struct aboutMutex
{
    pthread_mutex_t mutex;
    int i;

    aboutMutex()
    {
        do some thing;
    }    
};

void* testThread(void *id)
{
   do some thing
}

void testFunc(testStruct* myThis)
{
   aboutMutex am0;
   aboutMutex am1;

 
   int t0, t1, t2, t3;

   // 线程1和线程2传参am0
   pthread_create(&t0, NULL, testThread, &am0);
   pthread_create(&t1, NULL, testThread, &am0);

   // 线程3和线程4传参am1
   pthread_create(&t2, NULL, testThread, &am1);
   pthread_create(&t3, NULL, testThread, &am1);
}

线程1和线程2,对于testThread函数的执行,会由am0.mutex来同步,并都会操作am0.i;
线程3和线程4,对于testThread函数的执行,会由am1.mutex来同步,并都会操作am1.i;

类比到同一个class的不同对象,假设为o0和o1,
不同的线程操作同一个对象o0的成员函数时,会由o0.mutex来同步,类似于amo.mutex;
不同的线程操作同一个对象o1的成员函数时,会由o1.mutex来同步,类似于am1.mutex;

点2:----------------------------------------------------------------

其实这个源自于一个很基本的问题,
为什么全局和静态变量生命周期是跨越整个进程执行期间的?
又为什么栈变量生命周期就是单个函数执行期间?

我觉得关键其实在于,要理解在内存(虚拟内存,下同)意义上,变量还活着是什么意思,变量死了是什么意思?

我认为,变量在底层角度来说,就是一个内存位置。变量活着的意思,即变量还在内存占着位置;变量死了的意思,即变量已经不在内存中占着位置了。
全局和静态变量,与栈变量的生命周期差别,背后原因是它们所在内存区域的特性。
数据段(还有bss段) 在进程没死之前,其中的变量就在那静静地摆着,变量与内存位置能看着绑定了,所以只要进程不死,全局和静态变量就还活着;
栈段则不同,当前函数执行完毕之后,属于当前函数的那一片栈就作废了,当前函数中的变量自然就已经没了自己的内存位置了。所以只要所属函数死了,相应的栈变量也就死了;

画个了示例图,进行了很大的简化,只有数据段和栈段:
当func0被调用时,内存状况:
在这里插入图片描述
当func0执行完,func1被调用时,内存状况:
在这里插入图片描述

点3:----------------------------------------------------------------

明白了变量的生命周期问题后,感觉点3也就很容易理解了。
锁要生效,需要
同步的线程所用的锁要是同一把
确保锁变量还活着

同一把锁这个基本没什么问题,pthread_create()创建线程并传参的时候,想要同步的线程给传过去同一把锁便是。

问题是怎么确保锁还活着,写简单demo的时候,直接会整个全局锁,那样肯定可以了,直接就是不死锁王,但是在栈上的锁呢?
研究完点2,重新理清楚思路之后,发觉这个问题挺简单的。还是总是用全局锁,以致于把锁和普通变量过度区别的问题,还是那句嘛,锁也就是变量而已。
其实在C和C++中,传指针是很日常的事情,这些被传指针的变量,和被传了指针的锁,本质上还不就是一样的,都是跨栈访问变量而已,区别也就是从什么栈跨到了什么栈。

也画了个传指针跨栈访问的示意图,老千层饼了:
C++类成员函数在多线程中的一些问题研究_第2张图片
所以问题变得简单了
要确保锁活着,那么只要锁所在的那块栈帧活着就行了啊,和跨栈访问局部变量压根就没区别。在多线程中,有个显而易见的简单解决方法,在父线程上弄锁,然后做好安全措施,让父线程别在子线程结束前死了,那锁的存活也就有保证了。

你可能感兴趣的:(多线程)