在多线程编程当中,经常会遇到一些诸如“同步/异步”,“并发/并行”这样的相似基本概念。
这些概念对初学者来说往往很容易混淆,网络上分散着各式各样的说法,有些甚至是矛盾的,这就导致了一个经典问题,“不知道该听谁的”。
因此我在这里谈一谈我对这些概念的主观理解,不是教你怎么想,而是看我怎么想。
事实上,你也不用听谁的,重点是多动手实践思考,然后总结经验形成自己的理解。
单纯的复刻他人的想法,容易遗忘的同时,也没法在生产实践中能很好的指导你,到底不是自己的东西,尽信书不如无书。
线程是对CPU资源的抽象/虚拟化
计算机中盛行抽象文化,这里的抽象指的是,一种通过忽略底层细节,只展示关键部分,使系统更加易用的手段。
CPU本质上是一个复杂电路,它集结了各种各样的功能。
因此重点是如何控制它,使用它提供的功能,来完成我们需要的计算任务。
而CPU的基本运转机制可以归结为,取指执行。
没错,就这么简单,取出一条指令,然后解析指令,再就是调用相应的功能模块,最后保存结果,执行下一条指令。
那么所谓的获取CPU资源,就是拥有执行指令的能力,驱动CPU为我们服务的能力。
说是让CPU服务我们,但毕竟生殖隔离,没那么容易,CPU只是一个无情的执行指令的机器。
所以提出了线程这个概念,描述的就是可以获取CPU资源的对象,或者说CPU服务的对象。
假想着CPU正在服务某个东西,同时这个东西又承载着我们的任务的具体细节。
这个东西就是线程,通过这个抽象概念,打破了生殖隔离,构建了人类与机器的联系,这样我们的设想就可以交由机器来实现。
操作系统控制着CPU,使它可以服务多个线程,一会服务一下A线程,一会又服务一下B线程。
这也叫做调度,也就是常说的,调度的基本单位是线程。
调度说白了,就是改变CPU的服务对象,也就是线程,这样不同的功能代码,都有机会得到推进。
进程是程序的一次执行实例
程序就是一堆躺在硬盘里的数据,只是说其中的数据,根据不同的目的,有不同的划分。
创造程序的目的就是为了让他完成我们指派的工作。
想要让他完成我们指派的工作,他就必须动起来,不能让他躺平。
让躺着的程序动起来,他就成为了进程。
这个动,指的是被CPU驱动,同时也占用着其它的系统资源。
所以进程是一个更大的概念,因为驱动程序的推进,不仅仅需要CPU资源,还需要内存/网络/硬盘等等资源。
进程描述的就是程序动起来之后,所拥有的各种资源。
把这些系统资源都放到一块,操作系统就可以很方便的进行管理了,进程就是各种资源的集合。
所以常常会看见:
线程和进程这两个概念的核心就是虚拟化,而虚拟化的目的就是区分隔离。
就好比为什么一个房子要划分好几个房间,该留有的隐私要留,该区分的事情要区分。
搞混的原因,我猜测是由于没有明确线程的概念,说进程是调度的基本单位。
若是默认进程只有一条执行流,而这条执行流和进程属于一一对应的关系,那确实是可以把进程看作调度的基本单位,是方便一些。
这样的想法我认为不完全准确,因为这只是片面的考虑到了CPU资源,而没有考虑到其它系统资源;不过这也可能是因为教科书为了突出主题,选择性忽略了,但我们应该心中有数。
而常规来说,在没有特意引入多线程设计的情况下,一个程序确实也就只有一条执行流,并且是一一对应的,俗称主线程。
但若是进程拥有多条执行流,就没办法说进程是调度的基本单位了,因为调度某个进程运行,那到底是选哪一个/哪几个执行流推进呢?
那就需要具体区分这些执行流了,嗯,线程。
并发是一种程序设计的方案,是主观上希望程序拥有同时做多件事的能力
可以先对比一下非并发程序的设计,典型的如下所示:
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;
}
在这个新例子当中,我显式的引入了线程,并且把fun1
,fun2
都交给线程去执行。
这体现出的是,我拥有明显的意图去让事情有同时执行的可能,这是一种并发的程序设计方案。
在这样的情况下,不能再假设fun1
和fun2
之间的时序关系:
fun1
先完成,fun2
后完成fun2
先完成,fun1
后完成fun1
,fun2
同时完成对比起非并发的程序设计方案,一个重要的区别就是,我是带有明显意图,希望可以有多件事是同时执行的。
非并发/并发,指的是一种程序设计的方案,它们彼此相对,表达了编码者的主观想法,是否希望事情有同时执行的可能。
注意我的用词,并发是让事情的同时执行成为“可能”,这并不意味着就一定是同时执行的。
可以预见的是,对于多件事情的执行来说,并发不一定同时,但非并发一定不同时。
并行是指在物理上支持事情的同时执行,并且实际发生了这样的同时执行。
什么叫“物理上支持事情的同时执行”?
简单来说,我们必须拥有同时执行多件事情的功能器件,这就叫物理上支持。
在早期,CPU只有一个核心(仅考虑该核心中只有一个小核的情况),那无论你创建多少个线程,采取怎样的并发策略,主观上无论有多么想让事情同时执行,都是不可能的。
因为这在物理上就被封死了,根本没有能支持同时执行的功能器件。
只是这一个CPU核心,在线程间快速地切换(如上所述,服务不同线程),为你营造了一种同时执行的假象,但实际同一时间,只有一个线程在被推进执行。
当然,这是早期的情况,只不过现在很多课程为了教学方便,也只是选用了单核的CPU。
而时代发展至今,CPU里面早就不只是一个核了,已经拥有好几个核心了,甚至有的核心里面还有两个小核,也就是所谓的超线程(此处不考虑这个机制)。
超线程技术,一个物理核心可以模拟出两个逻辑核心,每个逻辑核心都能处理一个线程。操作系统会将这两个逻辑核心视为独立的处理单元,从而同时执行两个线程。
这就是在物理上支持事情的同时执行,确实是有了同时执行多件事情的功能器件(多个CPU大核)。
只有在这种物理支持下,才有可能是真正意义上的,同时执行多件事情,在同一时间确实能有多件事情被推进。
当我们采用并发的方案来设计程序,创建了多个线程,并且每个线程都分配到了独立的CPU核,那么这些线程就可以同时执行,达到并行。
另外,如果采用的是非并发程序设计,那即便给你一万个核,你的程序也只能在一个核上面跑(因为只有一个主线程,只能被一个核服务),在不考虑打开多个同样程序的情况下,那也只能是执行一件事,不可能同时推进多件事。
还要注意的是,在采用并发方案的情况下,并且物理上有多个CPU核,也不见得就一定是并行。
我们考虑创建了两个线程的例子,若是操作系统本身不为这两个线程分配独立的CPU核资源,而是只用一个CPU核来服务这两个线程(即便有多个核,操作系统一样有自由不用),那这和单核的情况是一样的,线程并没有同时被推进。
所以还要把操作系统这一调度管理者的角色给考虑进来,也不仅仅是物理上支持就足够的,也就是说实际发生了同时执行才能说是真正意义的并行。
本文作者:Test
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!