计算机操作系统对于并发性和并行性的概念给出的定义是:
并行性是指两个或多个事件在同一时刻发生; 并发性是指两个或多个事件在同一时间段内发生。
并发是指多个任务(线程)都请求运行,如果系统只有一个CPU,CPU只能按受一个任务,它把CPU运行时间划分成若干个时间段,再将时间段分配给各隔较短,使人感觉多个任务都在运行。
个线程安排轮流进行,在一个时间段的线程代码运行时,其它线程处于挂起状态。由于时间间 并行就是多个任务(线程)同时运行,就是甲任务进行的同时,乙任务也在进行,丙、丁任务等都在进行,当系统有一个以上CPU时,当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,多个线程互不抢占CPU资源,可以同时进行。
关于多进程和多线程,经常提到的就是进程所需的开销大,线程的开销小,线程比进程有优势。 但在真实的场景中使用并发时对多进程和多线程的选择没有这么简单。需要综合考虑各种因素。特别是对于进程池和线程池来说,进程和线程的数量很少,系统开销对比往往不是选择的主要因素。
对比项 | 多进程 | 多线程 | 结论 |
数据共享与同步 | 数据共享复杂,需要用IPC;数据是分开的,同步简单 | 多线程共享进程数据,数据共享简单;但也是因为这个原因导致同步复杂 | 各有优劣 |
硬件资源 | 进程需要的系统资源更多,但执行大任务时效率更高 | 线程时“轻量级进程”,系统开销小,执行小任务更有优势 | 线程占优 |
创建销毁及切换 | 开销大 | 开销远小于进程 | 线程占优 |
编程调试 | 简单 | 复杂 | 进程占优 |
可靠性 | 进程间互不影响 | 一个线程挂掉将导致整个进程挂掉 | 进程占优 |
可扩展性 | 适用于多核、多机部署 | 单机部署 | 进程占优 |
多线程应用目的:
为了划分关注点而使用:当编写一些需要同时操作的功能时,通过线程进行功能分离从而降低难度,提高响应速度。这种做法一般都是基于设计理念,而不是为了增加系统吞吐量,线程的数量和cpu硬件线程数无关。多见于用户界面。
为了性能:为了充分利用多核cpu,将任务分解为多个部分各自并行运行从而降低总的运行时间;或者增加单位时间内系统的吞吐量。
注意:线程自身也是消耗资源的,windows上典型的线程栈带下为1M,linux一般达到10M;而且运行越多的线程,操作系统就需要做越多的上下文切换,将过多的时间消耗在上下文切换上,反而会导致程序下降。所以如果试图优化系统性能,必须根据cpu硬件线程数调整线程数量。
使用多线程优化系统性能和其它的性能优化策略一样,容易是代码复杂化,更难于开发和维护,只有当性能成为瓶颈的时候才值得去做。
编写多线程程序首先要考虑的就是把程序写正确,正因为线程间共享数据的简单和直接,导致非常容易使用错误的方法共享数据。写一个线程安全的程序远比写一个线程安全的类要复杂的多。线程安全的类只需使用同步原语保护好类内部状态即可。但C++对象的构造和析构无法通过对象自身的mutex进行保护。
一般使用mutex和condition_variable就足以满足要求。
特殊情况考虑使用读写锁以提高效率。
不要使用递归锁,递归锁可能导致多层锁操作之间引发问题(例如内层锁修改了外层锁访问的变量,导致外层出现问题),而且mutex死锁更容易调试,通过定义NoLock版函数避免递归锁的使用。
使用atomic变量而不要简单的用volatile。
不用使用sleep进行线程同步操作,sleep不是线程同步原语。需要延时操作的地方使用互斥量或者条件变量的time_wait替代。
对象构造:
不要在对象构造函数中注册毁掉函数
不要在对象构造函数中将this指针传递给跨线程对象 因为此时对象还未初始化成功,别的线程可能访问这个对象,造成不可预料的后果。
对象析构: 当一个对象跨越多个线程被访问时,如果对象析构了,而另一个线程正在访问对象的成员函数,程序就会崩溃,而这是对象内部mutex无法解决的问题。
void TestClass::DoSomething()
{
boost::unique_lock<boost::mutex> lock(m_mutex);
//调用资源
}
~TestClass()
{
boost::unique_lock<boost::mutex> lock(m_mutex);
//释放资源
}
c++11之后的标准和boost都提供了智能指针解决了这个问题,所以在多线程程序中,使用智能指针不仅是一个好习惯,更是保证程序线程安全的必须。
内存模型可分为静态内存模型和动态内存模型,静态内存模型主要涉及类的对象在内存中是如何存放的,即从结构(structural)方面来看一个对象在内存中的布局。 动态内存模型可理解为存储一致性模型,主要是从行为(behavioral)方面来看多个线程对同一个对象同时(读写)操作时(concurrency)所做的约束,动态内存模型理解起来稍微复杂一些,涉及了内存,Cache,CPU 各个层次的交互,尤其是在共享存储系统中,为了保证程序执行的正确性,就需要对访存事件施加严格的限制。
线程默认的内存序是 std::memory_order_seq_cst 以一个具体的例子来说明顺序一致性: 假设存在两个共享变量a,b,初始值均为0,两个线程运行不同的指令,如下表格所示,线程 1 设置 a 的值为 1,然后设置 R1 的值为 b,线程 2 设置 b 的值为 2,并设置 R2 的值为 a,请问在不加任何锁或者其他同步措施的情况下,R1,R2 的最终结果会是多少?
线程 1 | 线程 2 |
a = 1; | b = 2; |
R1 = b; | R2 = a; |
由于没有施加任何同步限制,两个线程将会交织执行,但交织执行时指令不发生重排,即线程 1 中的 a = 1 始终在 R1 = b 之前执行,而线程 2 中的 b = 2 始终在 R2 = a 之前执行 ,因此可能的执行序列共有 4!/(2!*2!) = 6 种 R1,R2 最终结果只有 3 种情况,分别是 R1 == 0, R2 == 1,R1 == 2, R2 == 0 和 R1 == 2, R2 == 1
但是编译器编译时会将指令重排以进行优化,可能出现R1 == 0, R2 == 0的结果。 另外,现代的 CPU 大都支持多发射和乱序执行,在乱序执行时,指令被执行的逻辑可能和程序汇编指令的逻辑不一致,在单线程条件下,CPU 的乱序执行不会带来大问题,但是在多核多线程时代,当多线程共享某一变量时,不同线程对共享变量的读写就应该格外小心,不适当的乱序执行可能导致程序运行错误。因此,CPU 的乱序执行也需要作出适当的约束。
经典的double check问题:
boost::shared_ptr<Resource> pResource;
boost::mutex resourceMutex;
void foo()
{
if (!pResource)
{
boost::unique_lock<boost::mutex> lock(resourceMutex);
if (!pResource)
{
pResource.reset(new Resource);
}
}
pResource->DoSomething();
}
我们将初始化过程分解就可以发现原因:
pResource.reset(new Resource);
//等效
Resource *pTemp = ::operator new(sizeof(Resource));
pTemp->Resource();
pResource.reset(pTemp);
}
实际执行的时候,第三步返回指针可能会在构造函数执行之前就完成了,所以通过第一次if (!pResource)判断的线程可能在一个未初始化构造的对象上执行了操作。
c++中的volatile关键字主要作用:
- 易变性,保证变量的每次读写都重新从内存读,而不会使用寄存器的值。保证下面循环可以退出
volatile bool flag = false;
Thread1()
{
flag = true;
}
Thread2()
{
while(!flag)
{
//线程循环
}
}
- 不可优化
void DoSomething()
{
volatile int a;
a = 1;
a = 2;
a = 3;
//其他操作
}
编译器优化时会直接优化成一句a=3,加了volatile关键字就不会进行这些优化
- volatile没有支持内存模型顺序性的功能
volatile bool flag = false;
int somevar = 0;
Thread1()
{
somevar = 1;
flag = true;
}
Thread2()
{
while (!flag)
{
//线程循环
}
assert(somevar == 1);
}
线程2的断言无法保证一定成立,因为somevar=1与flag = true的执行顺序是不确定的。
volatile在c++中不是线程同步的手段,多线程访问共享变量使用原子操作。
每个请求/事件创建一个单独的线程进行处理。创建销毁线程开销大,不能满足处理大量请求的场景。
while (true)
{
boost::function<void ()> task;
if (BlockingQueue.get(task))
{
boost::thread newThread(task);
newThread.detach();
}
}
需要考虑线程返回值或者同步需求的情况,可以使用future模式。
使用线程池
一些特殊的线程例如写程序日志等
或者叫做半同步半异步线程池,主线程处理I/O事件并解析然后再往队列丢数据,然后消费者读出数据进行应用逻辑处理;优点:简化编程将低层的异步I/O和高层同步应用服务分离,且没有降低低层服务性能。集中层间通信。
缺点:需要线程间传输数据,因此而带来的动态内存分配,数据拷贝,语境切换带来开销。高层服务不可能从底层异步服务效率中获益。
生产者消费者模式监听线程和工作线程间通过一个消息队列来交换数据。这会带来数据传递开销,。同时,监听线程和工作线程都需要去访问消息队列,造成 了资源的竞争,需要额外的同步机制来协调他们的行为,包括监听线程获取和释放资源锁,对应的工作线程获取和释放资源锁,以及监听线程在将一个请求放入队列 后通知工作线程带来的开销,我们称此为同步开销,HS/HA模式的同步开销大于L/F的同步开销,。一个请求由监听线程负责放入消息队列,但是却由工作线 程来处理,所以,每个请求都会造成一次线程上下文切 换,由此带来的开销我们称为上下文开销。
T (H/H)=T(多路分离)+T(分配)+T(处理)+T(同步)+T(数据传递)+T(上下文)
在LF线程池中,线程可处在3种线程状态之一: leader、follower或processor。处于leader状态的线程负责监听网络端口,当有消息到达时,该线程负责消息分离,并从处于 follower状态中的线程中按照某种机制如FIFO或基于优先级等选出一个来当新的leader,然后将自己设置为processor状态去分配和处 理该事件。处理完毕后线程将自身的状态设置为follower状态去等待重新成为leader。在整个线程池中同一时刻只有一个线程可以处于leader 状态,这保证了同一事件不会被多个线程重复处理。 缺点:实现复杂性和缺乏灵活性; 优点:增强了CPU高速缓存相似性,消除了动态内存分配和线程间的数据交换。
L/F模式处理一个消息的时间为多路分离、分配、处理的时间,加上线程管理时间,LF中多个线程共享一个事件源,所以,需要协调它们间的行为,即 有同步开销,L/F同步开销仅为申请/释放锁的开销,在LF处理请求过程中并不需要线程上下文切换,但是在线程由follower成为leader时需要 进行线程上下文切换,所以当两个请求同时到达时,这种上下文切换会影响第二个请求的处理时间,也会带来一定的上下文开销。 T(L/F)=T(多路分离)+T(分配)+T(处理)+T(同步)+T(上下文)