编辑
2025-01-14
底层知识
0

目录

进程与线程
线程(Thread)
进程(Process)
小结
并发与并行
并发(Concurrency)
并行(Parallelism)
小结
同步和异步
阻塞和非阻塞

在多线程编程当中,经常会遇到一些诸如“同步/异步”,“并发/并行”这样的相似基本概念。

这些概念对初学者来说往往很容易混淆,网络上分散着各式各样的说法,有些甚至是矛盾的,这就导致了一个经典问题,“不知道该听谁的”。

因此我在这里谈一谈我对这些概念的主观理解,不是教你怎么想,而是看我怎么想

事实上,你也不用听谁的,重点是多动手实践思考,然后总结经验形成自己的理解。

单纯的复刻他人的想法,容易遗忘的同时,也没法在生产实践中能很好的指导你,到底不是自己的东西,尽信书不如无书。

进程与线程

线程(Thread)

线程是对CPU资源的抽象/虚拟化

计算机中盛行抽象文化,这里的抽象指的是,一种通过忽略底层细节,只展示关键部分,使系统更加易用的手段。

CPU本质上是一个复杂电路,它集结了各种各样的功能。

因此重点是如何控制它,使用它提供的功能,来完成我们需要的计算任务。

而CPU的基本运转机制可以归结为,取指执行。

没错,就这么简单,取出一条指令,然后解析指令,再就是调用相应的功能模块,最后保存结果,执行下一条指令。

那么所谓的获取CPU资源,就是拥有执行指令的能力,驱动CPU为我们服务的能力。

说是让CPU服务我们,但毕竟生殖隔离,没那么容易,CPU只是一个无情的执行指令的机器。

所以提出了线程这个概念,描述的就是可以获取CPU资源的对象,或者说CPU服务的对象。

假想着CPU正在服务某个东西,同时这个东西又承载着我们的任务的具体细节。

这个东西就是线程,通过这个抽象概念,打破了生殖隔离,构建了人类与机器的联系,这样我们的设想就可以交由机器来实现。

操作系统控制着CPU,使它可以服务多个线程,一会服务一下A线程,一会又服务一下B线程。

这也叫做调度,也就是常说的,调度的基本单位是线程。

调度说白了,就是改变CPU的服务对象,也就是线程,这样不同的功能代码,都有机会得到推进。

进程(Process)

进程是程序的一次执行实例

程序就是一堆躺在硬盘里的数据,只是说其中的数据,根据不同的目的,有不同的划分。

  • 比如,程序名称/窗口标题这样的标识数据
  • 又比如,指挥CPU干活的代码数据

创造程序的目的就是为了让他完成我们指派的工作。

想要让他完成我们指派的工作,他就必须动起来,不能让他躺平。

让躺着的程序动起来,他就成为了进程。

这个动,指的是被CPU驱动,同时也占用着其它的系统资源。

所以进程是一个更大的概念,因为驱动程序的推进,不仅仅需要CPU资源,还需要内存/网络/硬盘等等资源。

进程描述的就是程序动起来之后,所拥有的各种资源。

把这些系统资源都放到一块,操作系统就可以很方便的进行管理了,进程就是各种资源的集合。

所以常常会看见:

  • “进程拥有一套自己的页表” 页表是对内存资源的抽象,代表着内存的管理
  • “进程包含着线程” 线程是对CPU资源的抽象,代表着对代码运行的管理
  • “进程拥有一个打开的文件表” 文件是对IO/网络等设备的抽象,代表着对设备的管理。

小结

线程和进程这两个概念的核心就是虚拟化,而虚拟化的目的就是区分隔离。

就好比为什么一个房子要划分好几个房间,该留有的隐私要留,该区分的事情要区分。

搞混的原因,我猜测是由于没有明确线程的概念,说进程是调度的基本单位。

若是默认进程只有一条执行流,而这条执行流和进程属于一一对应的关系,那确实是可以把进程看作调度的基本单位,是方便一些。

这样的想法我认为不完全准确,因为这只是片面的考虑到了CPU资源,而没有考虑到其它系统资源;不过这也可能是因为教科书为了突出主题,选择性忽略了,但我们应该心中有数。

而常规来说,在没有特意引入多线程设计的情况下,一个程序确实也就只有一条执行流,并且是一一对应的,俗称主线程。

但若是进程拥有多条执行流,就没办法说进程是调度的基本单位了,因为调度某个进程运行,那到底是选哪一个/哪几个执行流推进呢?

那就需要具体区分这些执行流了,嗯,线程。

并发与并行

并发(Concurrency)

并发是一种程序设计的方案,是主观上希望程序拥有同时做多件事的能力

可以先对比一下非并发程序的设计,典型的如下所示:

cpp
#include <iostream> void fun1() { std::cout << "Hello Fun1\n"; } void fun2() { std::cout << "Hello Fun2\n"; } int main() { fun1(); fun2(); return 0; }

上面这段程序想传达的意图非常明显,有两件事要做,先做fun1,再做fun2

在C/C++或类似的程序设计语言当中,每条语句都是按顺序编排的,这意味着执行就是一件事接着一件事。

因此,在常规情况下,同一时间下,是不可能同时做多件事情的。

要注意,这里所提到的常规情况,指的就是上面提到的,仅有一条执行流的情况(主线程)。

所以,天然默认的程序设计方案就是顺序不同时,也就是非并发

这样的程序设计方案优势就在于,非常贴合我们日常的思考习惯,基本上就是对思维的直接翻译,简单易懂。

但也有缺点,那就是做事的效率不够高,在某些情况下,一些事情是可以同时完成的。

在上面给出的设计中,可以看出我没有明显的意图去让事情有同时执行的可能,接下来看一个新的例子:

cpp
#include <thread> #include <iostream> void fun1() { std::cout << "Hello Fun1\n"; } void fun2() { std::cout << "Hello Fun2\n"; } int main() { std::jthread t1(fun1); std::jthread t2(fun2); return 0; }

在这个新例子当中,我显式的引入了线程,并且把fun1fun2都交给线程去执行。

这体现出的是,我拥有明显的意图去让事情有同时执行的可能,这是一种并发的程序设计方案。

在这样的情况下,不能再假设fun1fun2之间的时序关系:

  • fun1先完成,fun2后完成
  • fun2先完成,fun1后完成
  • fun1fun2同时完成

对比起非并发的程序设计方案,一个重要的区别就是,我是带有明显意图,希望可以有多件事是同时执行的。

非并发/并发,指的是一种程序设计的方案,它们彼此相对,表达了编码者的主观想法,是否希望事情有同时执行的可能。

注意我的用词,并发是让事情的同时执行成为“可能”,这并不意味着就一定是同时执行的。

可以预见的是,对于多件事情的执行来说,并发不一定同时,但非并发一定不同时。

并行(Parallelism)

并行是指在物理上支持事情的同时执行,并且实际发生了这样的同时执行。

什么叫“物理上支持事情的同时执行”?

简单来说,我们必须拥有同时执行多件事情的功能器件,这就叫物理上支持。

在早期,CPU只有一个核心(仅考虑该核心中只有一个小核的情况),那无论你创建多少个线程,采取怎样的并发策略,主观上无论有多么想让事情同时执行,都是不可能的。

因为这在物理上就被封死了,根本没有能支持同时执行的功能器件。

只是这一个CPU核心,在线程间快速地切换(如上所述,服务不同线程),为你营造了一种同时执行的假象,但实际同一时间,只有一个线程在被推进执行。

当然,这是早期的情况,只不过现在很多课程为了教学方便,也只是选用了单核的CPU。

而时代发展至今,CPU里面早就不只是一个核了,已经拥有好几个核心了,甚至有的核心里面还有两个小核,也就是所谓的超线程(此处不考虑这个机制)。

超线程技术,一个物理核心可以模拟出两个逻辑核心,每个逻辑核心都能处理一个线程。操作系统会将这两个逻辑核心视为独立的处理单元,从而同时执行两个线程。

这就是在物理上支持事情的同时执行,确实是有了同时执行多件事情的功能器件(多个CPU大核)。

只有在这种物理支持下,才有可能是真正意义上的,同时执行多件事情,在同一时间确实能有多件事情被推进。

当我们采用并发的方案来设计程序,创建了多个线程,并且每个线程都分配到了独立的CPU核,那么这些线程就可以同时执行,达到并行

另外,如果采用的是非并发程序设计,那即便给你一万个核,你的程序也只能在一个核上面跑(因为只有一个主线程,只能被一个核服务),在不考虑打开多个同样程序的情况下,那也只能是执行一件事,不可能同时推进多件事。

还要注意的是,在采用并发方案的情况下,并且物理上有多个CPU核,也不见得就一定是并行。

我们考虑创建了两个线程的例子,若是操作系统本身不为这两个线程分配独立的CPU核资源,而是只用一个CPU核来服务这两个线程(即便有多个核,操作系统一样有自由不用),那这和单核的情况是一样的,线程并没有同时被推进。

所以还要把操作系统这一调度管理者的角色给考虑进来,也不仅仅是物理上支持就足够的,也就是说实际发生了同时执行才能说是真正意义的并行。

小结

同步和异步

阻塞和非阻塞

本文作者:Test

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!