CPP 多线程编程、互斥、同步 浅析

https://www.cnblogs.com/lidabo/archive/2012/08/15/2640204.html
https://blog.csdn.net/QIANGWEIYUAN/article/details/88792621
https://blog.csdn.net/xy_cpp/article/details/81910513
http://www.360doc.com/content/17/0506/10/8392_651520213.shtml
Remzi的OSTEP 操作系统导论

本文主要分为两块,第一块介绍互斥,第二块是C++ 11中的多线程编程学习笔记。阅读时间大约40分钟。

多线程编程

  • 互斥、原子操作及硬件原语
    • 1.关闭中断
    • 2.test-and-set
    • 3.compare-and-swap
    • 4.load-link and store-conditional
    • 5.fetch-and-add
    • 小结
    • 并发数据结构
      • 1、并发计数
      • 2、并发链表
      • 3、并发队列
  • 多线程编程学习笔记
    • 怎么选定新线程的执行函数?
    • 新线程是怎么结束的?
    • 并发问题
      • 原子操作与互斥
    • 一些现成的锁
      • mutex
      • lock_guard 和 unique_lock

互斥、原子操作及硬件原语

为什么需要互斥?为什么有临界区?
因为中断随时可能发生,而中断必不可少

1.关闭中断

最早为了实现互斥,使用控制中断的方法——在临界区关闭中断。
很好理解,处理器自闭了,和每个进程(线程)轮番上演吊死在一棵树上的戏码。
缺点:特权滥用,害怕恶意进程,不支持多处理器,以及,中断很重要!(比如磁盘完成了读取请求但是由于没有中断,等待读取的进程无从得知这一事实)

2.test-and-set

测试并设置指令(test-and-set)——一种原子交换,自旋性能损耗大,有硬件支持,不会因为不可预知的中断导致失去原子性和正确性

int TestAndSet(int *old_ptr,int new)//原子性的把旧值变为新值,并且返回旧值
{
	int old=*old_ptr;
	*old_ptr=new;
	return old;
}

typedef struct lock_t
{
	int flag;
}lock_t;

void init(lock_t *lock)
{
	lock->flag=0;
}

void lock(lock_t *lock)
{
	while(TestAndSet(&lock->flag,1)==1)  //还有一种错误写法 :while(lock_t->flag==1) ; lock_t->flag=1;
		;//	自旋                                                                                   
}

void unlock(lock_t *lock)
{
	lock->flag=0;
}

3.compare-and-swap

比较并交换(SPARC系统上是compare-and-swap,x86上是compare-and-exchange)
只需要将TestAndSet函数换为以下函数即可:

int CAS(int *old_ptr,int expected,int new)
{
	int old=*old_ptr;
	if(old==expected)  //本行就是比test-and-set添加的部分
		*old_ptr=new;
	return old;
}

4.load-link and store-conditional

连接的加载和条件式存储指令

int LoadLinked (int *ptr)
{
	return *ptr;
}

int StoreConditional(int *ptr,int value)
{
	if(加载的地址在这段期间都没有更新过)
	{
		*ptr=value;
		return 1;
	}
	else
		return 0;
}

void lock(lock_t *lock)
{
	while(1)
	{
		while(LoadLinked(&lock->flag)==1) //锁目前不可用就自旋
			;
		if(StoreConditional(&lock->flag,1)==1)//如果说成功把*ptr更新为1,那么获取锁成功,否则失败
			return;
	}
}

void unlock(lock_t *lock)
{
	lock->flag=0;
}

1、2、3、4均是硬件原语的C语言伪代码,它们实现了正确性(原子性),但是没有考虑公平性。

5.fetch-and-add

直译为:获取并增加。
其代码结构与test-and-set类似,但加入了ticket和turn变量实现了轮转,考虑了公平性,能保证所有线程都能抢到锁。

小结

1、2、3、4、5均属于自旋锁,不同系统有具体的构建更有效率的锁的方法,大部分都使用休眠代替了自旋,详细可参见OSTEP的第28章第230页。

并发数据结构

说完了并发环境下锁、互斥的几种实现,说说如何用于数据结构吧。

1、并发计数

为了在多核系统中提高并发性能,需要维护好临界区。
显然,如果每一次简单的计数操作(比如++)都需要生成锁、释放锁,性能上十分不划算。
因此,**要偷懒**,不实时更新计数值!
引入多个局部计数器,一个全局计数器,比如说4个CPU则有4个局部计数器。
**懒惰带来性能!**
常用的实现是设置一个阈值S,比如S=100万则4个CPU上的每个计数器上的值到达100万时,加一次锁,把值汇总到全局计数器。
S变大则性能变好,计数器延迟增大。S变小则性能较差,延迟较小。

2、并发链表

这种数据结构在加锁时可能出现问题——锁还占用着,程序崩溃了——比如说在插入链表时,如果我们把获取锁放在程序开头,释放锁放在程序结尾,程序确实不会有临界区冲突的问题,但是如果在释放锁之前,程序崩溃,那么锁就会....
以上问题的解决方案:使得临界区更精准,而不是大大咧咧的整个把程序括起来,从而让释放锁之前绝不崩溃 ——具体的说,临界区应该是包含**可能受到中断影响的部分**(比如指针)的最小代码段。

3、并发队列

队列头尾各一个锁。
思路与并发链表类似——保护好敏感值(通常是指针)。

欢迎大家勘误及留言~(Remzi的书真的很不错!

多线程编程学习笔记

C++ 11中创建线程非常简单,使用std::thread类就可以,thread类定义于thread头文件,构造thread对象时传入一个可调用对象作为参数(如果可调用对象有参数,把参数同时传入),这样构造完成后,新的线程马上被创建,同时执行该可调用对象;

怎么选定新线程的执行函数?

// 如果fuc1是一个普通函数并且有两个参数,API是这样的:
thread thread1(fuc1, arg1, arg2);
// 当然,也可以传递零个或更多个函数参数。

新线程是怎么结束的?

新线程可以自行返回,也可以强行杀掉。
这两种方式有优劣之分吗?答案是有。
先放结论:最好让新线程自行返回(使用join或detach),而不是强行杀掉。

理由:(非原创,资料难以考究最初源头)

无论在windows中,还是Posix中,主线程和子线程的默认关系是:
无论子线程执行完毕与否,一旦主线程执行完毕退出,所有子线程执行都会终止。这时整个进程结束或僵死(部分线程保持一种终止执行但还未销毁的状态,而进程必须在其所有线程销毁后销毁,这时进程处于僵死状态)

需要强调的是,线程函数执行完毕退出,或以其他非常方式终止,线程进入终止态(请回顾上面说的线程状态),但千万要记住的是,进入终止态后,为线程分配的系统资源并不一定已经释放,而且可能在系统重启之前,一直都不能释放。终止态的线程,仍旧作为一个线程实体存在于操作系统中。(这点在win和unix中是一致的)而什么时候销毁线程,取决于线程属性。

通常,这种终止方式并非我们所期望的结果,而且一个潜在的问题是未执行完就终止的子线程,除了作为线程实体占用系统资源之外,其线程函数所拥有的资源(申请的动态内存,打开的文件,打开的网络端口等)也不一定能释放。所以,针对这个问题,

主线程和子线程之间通常定义两种关系:
可会合(joinable)。这种关系下,主线程需要明确执行等待操作(使用join)。在子线程结束后,主线程的等待操作执行完毕,子线程和主线程会合。这时主线程继续执行等待操作之后的下一步操作。主线程必须会合可会合的子线程,即使子线程能够在主线程之前执行完毕,进入终止态,也必须显式执行会合操作,否则,系统永远不会主动销毁线程,分配给该线程的系统资源(线程id或句柄,线程管理相关的系统资源)也永远不会释放。
相分离(detached)。顾名思义,这表示子线程无需和主线程会合,也就是相分离的(使用detach)。这种情况下,子线程一旦进入终止态,系统立即销毁线程,回收资源。这种方式常用在线程数较多的情况,有时让主线程逐个等待子线程结束,或者让主线程安排每个子线程结束的等待顺序,是很困难或者不可能的。所以在并发子线程较多的情况下,这种方式也会经常使用。
缺省情况下,创建的线程都是可会合的。可会合的线程可以通过调用detach()方法变成相分离的线程。但反向则不行。

https://www.cnblogs.com/lidabo/archive/2012/08/15/2640204.html //如果你确定想杀掉线程,看这里

join与detach简单的示例

如果有这样一个函数:

void increase(int * p, int times)
{
	for (int i = 0; i < times; ++i)
	{
		++(*p);
	}
}

如果有这样一个主函数:

int main()
{
	int num = 0;
	thread thread1(increase, &num, 100000);
	thread1.detach();//分离线程
	cout << num;
}

运行多次主函数,会发现输出的num值是不等的,这是因为主线程不等待新线程。
如果将detach()换为join(),则输出的num值总是100000。

并发问题

提到并发,首先需要区分并发(concurrency)和并行(parallel):

并行是说同一时刻有多条命令在多个处理器上同时执行。并发是说同一时刻只有一条指令执行,只不过进程(线程)指令在CPU中快速轮换,速度极快,给人看起来就是”同时运行”的印象,实际上同一时刻只有一条指令进行。但实际上如果我们在一个应用程序中使用了多线程,线程之间的轮换以及上下文切换是需要花费很多时间的。所以采用一个拥有两个线程的进程执行所需要的时间比一个线程的进程执行两次所需要的时间要多一些。即采用多线程不会提高程序的执行速度,反而会降低速度,但是对于用户来说,可以减少用户的响应时间。

多线程编程的目的,就是"最大限度地利用CPU资源",当某一线程的处理不需要占用CPU而只和I/O,OEMBIOS等资源打交道时,让需要占用CPU资源的其它线程有机会获得CPU资源。

凡事皆有代价。多线程可以提高CPU利用率,但是也带来了并发问题。
举个例子,如果多线程都存在一个喝水操作:

喝水分为几步?也许是这样的:
1、拿起杯子
2、拧开杯盖
3、张嘴
4、将水倒入嘴里
5、拧紧杯盖并休息会(类似于I/O操作,在休息时间里不能喝水)

这杯水是临界区,多线程并发意味着有很多人想同时喝同一杯水,这是竞态

假设水杯的初始水量是sum,每次喝量为n的水。sum == 10 * n

在单线程世界里,一切都很简单:顺序执行1-5即可。
一杯水需要喝10次才能喝完,那就是执行10次1-5。期间有10次休息时间。

如何更快喝完这杯水?答案很简单:在休息时,让另一个人喝水。因此引入多线程。

在多线程世界里,如果不考虑同步问题,如果有两个想喝水的人A和B,也许是这样的:

1、A执行1-3,此时正准备喝水,水的总量是sum。此时发生中断,B说:我要喝水。A就慷慨的让B先喝。

2、B执行1-5,水的总量变为sum - n。

3、A执行4-5,水的总量变为sum - n。

各自执行 sum -= n;
最后,两个人都喝了水,水量却不是sum - 2 * n,而是sum - n。

为什么?

因为喝水操作不是原子性的,在进行上下文切换时,A从第1步取出水杯的当前量,切换到B。B喝完水,更新当前量为sum - n。从B切换回A时,A依然天真的以为B没有喝水(使用的依然是上下文切换前寄存器里的值),即当前量是sum,实际上当前量是sum - n。

用白话说,A与B存在信息差:明明B喝了水,A却不知道。信息差?换个词,用同步吧。

如何解决上述的喝水问题?有两种方式:
1、喝水只需要一步
2、A喝水时不允许B喝水

第1种方式是原子操作,第2种方式是互斥

原子操作与互斥

在历史长河里,有一段时间,大家(包括大名鼎鼎的Dijkstra)都热衷于研究不依赖硬件支持的锁机制。(见Remzi的操作系统导论 第222页)

但只需要很少的硬件支持,实现锁就会容易很多。
具备硬件支持的原子操作有:

test-and-set / compare-and-exchange / load-linked + store-conditional / fetch-and-add

(详见Remzi的操作系统导论 第28章)

最开始的互斥方案是在单处理器上关闭中断。这样就可以保证操作的原子性。

最开始的实现是自旋锁,为了避免浪费CPU资源,第二个实现是“立即让出”,这解决了上面一个问题但避免不了饿死。于是使用休眠代替自旋(通过队列实现)。

假设我们有一个两个处理器core1和core2计算机,现在在这台计算机上运行的程序中有两个线程:T1和T2分别在处理器core1和core2上运行,两个线程之间共享着一个资源。

首先我们说明互斥锁的工作原理,互斥锁是是一种sleep-waiting的锁。假设线程T1获取互斥锁并且正在core1上运行时,此时线程T2也想要获取互斥锁(pthread_mutex_lock),但是由于T1正在使用互斥锁使得T2被阻塞。当T2处于阻塞状态时,T2被放入到等待队列中去,处理器core2会去处理其他任务而不必一直等待(忙等)。也就是说处理器不会因为线程阻塞而空闲着,它去处理其他事务去了。

而自旋锁就不同了,自旋锁是一种busy-waiting的锁。也就是说,如果T1正在使用自旋锁,而T2也去申请这个自旋锁,此时T2肯定得不到这个自旋锁。与互斥锁相反的是,此时运行T2的处理器core2会一直不断地循环检查锁是否可用(自旋锁请求),直到获取到这个自旋锁为止。

从“自旋锁”的名字也可以看出来,如果一个线程想要获取一个被使用的自旋锁,那么它会一致占用CPU请求这个自旋锁使得CPU不能去做其他的事情,直到获取这个锁为止,这就是“自旋”的含义。

当发生阻塞时,互斥锁可以让CPU去处理其他的任务;而自旋锁让CPU一直不断循环请求获取这个锁。通过两个含义的对比可以我们知道“自旋锁”是比较耗费CPU的。

一些现成的锁

#并发问题#里讲了讲并发问题的存在及渊源,那么,具体到C++ 11编程,使用哪些API呢?
先放一个多线程不安全的例子:

#include
using namespace std;
mutex mutex_t;
void increase(int * p, int times)
{
	int index = 0;
	while (index++ < times)
		++(*p);
}
int main()
{
	int num = 0;
	thread thread1(increase, &num, 300000);
	thread thread2(increase, &num, 300000);
	thread2.join();
	thread1.join();
	cout << "num = "<< num;
}

上述例子我们期望的结果是600000,显然未必能如愿。

mutex

#include
#include  //最简单的互斥锁
using namespace std;
mutex mutex_t;
void increase(int * p, int times)
{
	int index = 0;
	while (index++ < times)
	{
		mutex_t.lock();
		++(*p);
		mutex_t.unlock();
	}
}
int main()
{
	int num = 0;
	thread thread1(increase, &num, 300000);
	thread thread2(increase, &num, 300000);
	thread2.join();
	thread1.join();
	cout << "num = "<< num;
}

lock_guard 和 unique_lock

关于lock_guard 和 unique_lock 可以参考下面这篇文章
http://www.360doc.com/content/17/0506/10/8392_651520213.shtml

你可能感兴趣的:(cpp,多线程,mutex,thread)