并发编程 01 并发的基本知识

发布于 2021-09-18  103 次阅读


一、什么是并发

  在最简单和最基本的层面,并发指两个或多个独立的活动同时发生。例如,我们一边做……一边做……。

1.1 计算机系统中的并发

  对于单核处理器来说,其在某一时刻只能同时执行一个任务,但它可以每秒切换多次不同的任务。先做一点这个任务,然后做一点别的任务,这使得任务看起来是并行被得到了处理。这就是任务切换(Task Switching)。我们仍然将这样的系统称为并发(concurrency),因为任务切换得太快,以至于无法分辨任务在何时会被暂挂而切换到另外一个任务。任务切换给用户造成了一种并发的假象。由于这是并发的假象,这使得在单处理器下的任务切换,会与真正的并发相比仍有所不同。
  具有多个核心的处理器可以完成真正意义上的并发,称之为硬件并发(Hardware Concurrency)

1.2 并发的途径

  并发可以分为多进程并发和多线程并发。

1.2.1 多进程并发

  在一个应用程序中使用并发的的第一种方法是,将应用程序分为多个、独立的、单线程的进程,他们运行在同一时刻,就像你可以同时进行网页浏览和文字处理。这些独立的进程可以通过所有常规的进程之间的通信相互传递消息(藉由操作系统完成)。

缺点:

  • 进程之间的通信慢。系统保护一个进程对另一个进程的修改。
  • 多进程的固有开销大。进程启动时间、操作系统管理成本。

优点:

  • 更安全。由于系统的保护机制,多进程比多线程在数据保护上更具有优势。
  • 分布式。可以将程序部署到多个计算机上,每台计算机部署一个或多个进程,虽然通信成本增加,但它是一个并行的可用性和提高性能的低成本方法。

1.2.1 多线程并发

  并发的另一个途径是在单个进程中运行多个线程。线程像是很轻量级的进程:每个线程相互独立运行,且每个线程可以运行不同的指令序列。但进程中的所有线程,都共享相同的地址空间,并且从所有线程中访问到大部分数据——全局变量仍然是全局的,指针、对象的引用或数据可以在线程之间传递。虽然通常可以在进程之间共享内存,但这难以建立,并且难以管理,因为统一数据的内存地址在不同的进程中也不尽相同。

优点:

  • 开销小。

缺点:

  • 线程安全。

  开销小是系统赋予我们的天然优势,而线程安全的事情则被交给程序员自己去把握。于是我们后序工作都将围绕多线程展开。

二、为什么使用并发

  在应用程序中使用并发的原因主要有两个:关注点分离、性能。

2.1 划分关注点

  考虑一类带有用户界面的密集处理型应用程序:例如计算机的视频播放程序,计算机需要读取数据、播放数据,同时还要响应用户的操作。我们使用两个线程来完成这个程序,一个程序负责数据的读取与播放,另一个线程用于响应用户的操作。当用户按下暂停的时候,线程2可以立即暂停,而不必等待线程1读取数据的间隔。

2.2 性能

  有两种方式为了性能使用并发。首先,将一个任务分成几个部分并各自运行,从而降低总运行时间,这就是任务并行(Task Parallelism)。虽然这听起来很直观,但它可以是一个相当复杂的过程,因为各个部分直接可能存在许多依赖。区别可能是在过程方面——一个线程执行算法的一部分,而另一个线程执行算法的另一部分——或是在数据方面——每个线程在不同的数据部分上执行相同的操作。后一种方法被称为数据并行(Data Parallelism)

2.3 什么时候不用并发

  要知道何时不用与知道并发何时要用同样重要。基本上,不使用并发的唯一原因就是在收益比不上成本的时候。使用并发的代码在很多情况下难以理解,因此编写和维护多线程代码就与直接的脑力成本,同时额外的复杂性也可能导致编写错误。
  同样的,性能的收益可能不如预期那么大。在启动线程时存在的固有开销,因为操作系统必须分配像个的内核资源和堆栈,然后将线程加入调度器,这一系列操作都要占用时间。此外,过多的线程(线程数 >> 处理器线程数)也会导致操作系统花更多时间在调度上。

三、在C++中使用并发和多线程

#include <iostream>
#include <thread>

void hello()
{
    std::cout << "Hello world!" << std::endl;
}

int main()
{
    std::thread t(hello);

    t.join();
    // 令main()线程等待线程t完成,而不是可能直接结束

    return 0;
}

  值得注意的是,在这里我们需要给每一个线程绑定一个初始函数(Initial Function)。对于应用程序来说,初始线程是main(),而对于线程t来说,其初始函数是hello()。
  在启动新线程t后,初始线程将继续运行,如果它不等待新线程t结束,它就会自己运行到main()的结束。这也就是为什么我们在return 0之前加入了一个t.join(),以让两个线程同步。