C++11 多线程编程 学习总结(上)

基本概念

并发:一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

进程:可执行程序运行,便创建了一个进程。

线程

  • 就是代码的执行通路
  • 每个进程都有一个主线程,且唯一,与进程一起产生、结束
  • 多线程,不是越多越好,每个线程需要一个独立的堆栈空间,线程之间切换需切要保存很多中间的状态,切换过多降低程序的运行时间。

开启一个线程

整个进程是否执行完毕,取决于主线程是否执行完毕,即如果主线程执行完毕整个进程也结束,此时如果子线程没有结束,也会被强行结束。因此如果需要让子线程一保持正常运行,主线程一般会保持一致运行,但是也有例外(即detach)。

开启线程

1.需要用到的头文件: #include <thread>since supported by c++11;

2.线程,就是开辟一个新的通道去执行另一个函数,对象如下:

    1)普通函数

    2)仿函数(functor):类里面实现operator()

    3)Lambda表达式

3.使用的函数:

    1)join()

    2)detach()

    区别

    join()函数,让主线程等待子线程执行完成,相当于阻塞,join执行完毕,join()后面的主线程才会继续执行。这样可以有效防止主程序已经停止运行,而子程序没有运行结束,子程序不得不中断。

    detach()函数,让子线程和主线程分开独自执行,无法控制,该子线程由c++运行时库进行控制管理,驻留后台运行,运行结束也是由c++运行时库进行释放相关资源(守护线程)。但是当主线程结束,该子线程也会随即中断结束。

    注意事项

    1)在使用detach函数时,注意子线程和主线程之间不要使用“引用”方式来传递参数,否则某一个先结束,释放变量,会导致另一个线程出现bug。

    2)join()函数一般置于return语句前面,等待所有的子线程运行结束,主线程再结束。

 4.简单案例

#include <thread>
#include <iostream>
		
void foo() {
    std::cout << "subthread" << std::endl;
}
		
int main(int argc, char const *argv[]){
    std::thread thread(foo);    // 启动线程foo,并且已经开始执行
	thread.join();              // 让主线程等待子线程执行完成,相当于阻塞,join执行完毕,主线程再继续执行
	std::cout<<"I am main thread.\n";
	return 0;
}

    上面关键的两步:

        std::thread thread(foo);   

        thread.join();             

    首先要创建线程的实例,然后进行阻塞。由于Lambda表达式也可以创建函数,因此也可以用Lambda表达式代替foo,同理functor。

线程传参

1.detach引发的问题

    由于detach使得主线程和该子线程进行了分离,主线程不再等待该子线程完成就结束,就可能会导致如下问题:

    如果从主线程以引用的方式传参数给子线程,当主线程运行结束,该参数也被释放,就会导致子线程在接受参数时接收到的是一个被释放的内存区域,引发不可预料的问题。

    解决办法:

a)若传递int这种简单类型参数,建议都是值传递,不要用引用。防止节外生枝。

b)如果传递类对象,避免隐式类型转换。全部都在创建线程这一行就构建出临时对象来,然后在函数参数里用引用来接;否则系统还会构造一次对象

终极结论:

c)建议不使用detach(),只使用join():这样就不存在局部变量失效导致线程对内存的非法引用问题。

2.获取当前线程id

std::this_thread::get_id()

3.传递类对象的引用到线程函数,std::ref()函数

4.线程函数参数

  • 普通函数
  • 仿函数(functor)
  • 智能指针作为参数
  • 类的成员函数指针作为线程参数

(1)functor需要实现operator() ,调用如下,可见只是需要传入一个类的实例即可,自动的调用functor.

输入:
class baz{
public:
	void operator()(int n) {
	    std::cout<<"var address in sub: "<<&n<<"|var value in sub: "<<n<<std::endl;
		std::this_thread::sleep_for(std::chrono::milliseconds(10));
	}
	int n = 0;
};
			
int main(int argc, char const *argv[]){
    baz b;
    int n=10;
    std::thread t6(b,n); // t6 runs baz::operator() on object b 
			       
    std::cout<<"var address in main: "<<&n<<"|var value in main: "<<n<<std::endl;
    t6.join();
    return 0;
}
			
输出:
var address in main: 0x61fe38|var value in main: 10
var address in sub: 0x286fd38|var value in sub: 10

(2)智能指针作为函数参数

// 传入一个智能指针
void smartptr(std::unique_ptr<int> ptr){}
			
std::unique_ptr<int> smptr(new int(100)); //不能detach
std::thread threadPtr(smartptr, std::move(smptr));
threadPtr.join();
		
if (smptr == nullptr) 
    std::cout<<"null\n";

当使用智能指针作为参数时,只能使用std::move的方式将其传参。并且传参结束,主线程中的智能指针就变成nullptr。因此此时不能使用detach,只能使用join().

(3)使用类的成员函数

std::thread threadPtr(&class_name::foo, &class_instance, foo_args);

 mutex

创建和等待多个线程

(a)创建多个线程时,各个线程之间是顺序不定的执行,由系统调度。

(b)推荐join的写法,更容易写出稳定的程序。

(c)把thread对象放入到容器管理,看起来像个thread对象数组,这对我们一次创建大量的线程并对大量线程进行很方便。

 数据共享问题分析

 (1)只读的数据:是安全稳定的不需要特别什么处理手段,直接读就可以;

(2)有读有写:2个线程写,8个线程读,如果代码没有特别的处理,那程序肯定崩溃;

         最简单的不崩溃处理,读的时候不能写,写的时候不能读。2个线程不能同时写,8个线程不能同时读;

         写的动作分10小步;由于任务切换,导致各种诡异事情发生(最可能的诡异事情还是崩溃);

保护共享数据过程

(1)操作共享数据时候,为了保护共享数据,需要锁住共享数据。

(2)某个线程A,最先占用线程时,先lock()操作共享数据的代码。其他使用共享数据的线程必须等待当前占用共享数据的线程完程。

(3)A操作完成后,解锁unlock()。其他线程上。

(4)重复上述步骤操作共享数据。

 实现保护共享数据

1.互斥量:std::mutex

  • std::mutex mtx;

2.unlock()lock()成对使用。

  • mutex理解成一把锁。当线程占用共享数据时,先用mtx.lock()函数加锁。
  • 对于同一个mutex对象,一次只有一处mtx.lock()加锁成功,谁先抢占这分数据,就先lock()。只要lock()函数返回表示上锁成功,否则堵塞直到lock()成功。
  • 当该占用数据的线程结束时,应该使用unlock(),否则整个程序堵塞无法运行。

注意:需要保护的数据,lock()和unlock()应该设置为合适的范围。少了达不到效果,多了耗时。

3.为防止大家忘记unlock(),引入了一个叫std::lock_guard的类模板:你忘记unlock不要紧,我替你unlock;

std::lock_guard可直接取代lock()和unlock();std::lock_guard构造函数里执行了mutex::lock(),析构函数里执行了mutex::unlock();

std::lock_guard<std::mutex> lock_grd(mtx);自动的加锁/解锁

 死锁

(1)概念

当有多个mutex对象,至少两个,lock()的处理不当容易死锁。顺序一致不会死锁,相反才会死锁。

比如在两个线程里,分别如下对两份代码,进行lock()/ublock()。A线程里mtx_1.lock()后,若B线程mtx_2.lock()执行,那么A线程mtx_2.lock()要想执行成功,必须等待B线程mtx_2.unlock(),可是B中的mtx_1.lock()因为A线程的mtx_1.lock()已经执行又没unlock()无法执行,导致无法lock()。因此相互锁死。

 

线程A 线程B

mtx_1.lock();

mtx_2.lock();

//deal with somethig

mtx_2.unlock();

mtx_1.unlock();

mtx_2.lock();

mtx_1.lock();

//deal with sth

mtx_2.unlock();

mtx_1.unlock();

(2)预防死锁

  • 不同线程的的不同对象lock()顺序一致

  • 使用std::lock()模板

 std::lock()函数模板

互斥量可以实现一次2个及其以上,但是不会存在死锁问题。如果有一个互斥量没有锁住,就会等待,同时把锁住的互斥量释放,等待所有的互斥量都锁住,才会返回。即,要么都锁住,要么都没有锁住。

std::lock(mtx_1, mtx_2);

std::lock()函数模板让编译器自己lock(),怎么才能编译器自己unlock():配合std::lock_guard<std::mutex>

(a)这里需要给lock_guard传入第二个参数,告诉编译器不再需要lock,因为前面函数模板std::lock()已经lock一次了,lock_guard是为了编译期自己unlock

(b)需要传入第二个参数:std::adopt_lock

         std::lock(mtx_1, mtx_2);

         std::lock_guard<std::mutex> lockGrd1(mtx_1,std::adopt_lock);

         std::lock_guard<std::mutex> lockGrd2(mtx_2,std::adopt_lock);

unique_lock 类模板

unique_lock 也是为了能实现编译器自己lock/unlock

(1)相比较lock_guard,更加灵活,但是效率差,内存占用多。

(2)unique_lock构造函数的的第二个参数(理解为标志位):

(a)std::adopt_lock:使用提前必须已经lock,避免二次lock()。这样主要是不想自己手动unlock()。这一点和lock_guard()一致。

(b)std::try_to_lock:使用前提不能lock。尝试去锁定mutex,但是如果没有成功,不会阻塞在那,而是会立即返回。确定是否lock成功,可以通过owns_lock()判断。

例如:

std::unique_lock<std::mutex> uniGrd(mtx, std::try_to_lock);

if(uniGrd.owns_lock()) {

        //拿到了lock()

        //处理一些没有共享数据的代码

}

else{

        std::cout<<"thread fail to lock.\n";

}

(c)std::defer_lock:使用前提不能lock。它初始化一个没有lock过的unique_lock对象。

         unique_lock重要的成员函数配合defer_lock()

         + lock()

         + unlock() 主要是用于处理一些非共享数据的代码。需要不断地lock、unlock。以保证效率。

std::unique_lock<std::mutex> uniGrd(mtx_1, std::defer_lock);

//需要处理共享数据的代码

uniGrd.lock();

 

//处理一些没有共享数据的代码

 uniGrd.unlock();

                        

//下面又是一些需要处理共享数据的代码

uniGrd.lock();

msg.push_back(i);

std::cout<<"generator: "<<i<<std::endl

          + try_lock():尝试给互斥量加锁,如果拿到了就返回true,否则返回false.不阻塞。类似上面的try_to_lock。

          + release():返回它所管理的mutex对象指针,并且释放所有权。即unique_lock对象和mutex对象不再绑定在一起。一旦解除了关系,那么最终需要自己对这个互斥量对象unlock。

std::unique_lock<std::mutex> uniLock(mtx_1); // 编译器帮你lock

auto mtxPtr = uniLock.release();  //解除绑定

// do something

mtxPtr->unlock();   //解除绑定后,必须自己去unlock

         注意为什么有时需要unlock():因为你lock锁住的代码段越少,执行越快,整个程序运行效率越高。

                    有人也把锁头锁住的代码多少称为锁的 粒度,粒度一般用粗细来描述;

                            > 锁住的代码少,这个粒度叫细,执行效率高;

                            > 锁住的代码多,这个粒度叫粗,执行效率低;

                            要学会尽量选择合适的粒度,是高级程序员的能力和实力的体现;

 (3)unique_lock所有权的传递

  • 使用移动语义,std::move()。
  • mutex对象的所有权的传递只能移动不能复制,像极了unique_ptr

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章