可以先记住:
“并发”指的是程序的结构
“并行”指的是程序运行时的状态
并行(parallelism):就是同时执行的意思。判断程序是否处于并行的状态,就看同一时刻是否有超过一个“工作单元”在运行就好了。所以,单线程永远无法达到并行的状态。
并发(concurrency):并发指的是程序的“结构”。当我们说这个程序是并发的,实际上,这句话应当表述成“这个程序采用了支持并发的设计”。所以正确的并发设计的标准是:“使多个操作可以在重叠的时间段内进行。”
这句话的重点有两个:
“(操作)在重叠的时间段内进行”:它与前面说到的并行并不完全相同。并行,当然是在重叠的时间段内执行;但是另外一种执行模式,也属于在重叠时间段内进行,这就是协程。
在使用协程时,程序的执行看起来是这个样子的:
在这里插入图片描述
task1 和 task 是两段不同的代码,比如两个函数(一个是处理行情的函数,一个是处理交易的函数),其中黑色块代表某段代码正在执行。注意,这里从始至终,在任何一个时间点上都只有一段代码在执行。但由于 task1 和 task2 在重叠的时间段内执行,所以这是一个支持并发的设计。与并行不同,单核单线程支持并发。
在这里插入图片描述
“可以在重叠的时间段内进行”中的“可以”两个字:意思是正确的并发设计使并发执行成为可能,但是程序在实际运行时却不一定会出现多个任务执行时间段重叠的情形。比如:我们的程序会为每一个任务开一个线程或协程,只有一个任务时,显然不会出现多个任务执行时间段重叠的情况,有多个任务时,就出现了。
这里就可以看到,并发并不描述程序执行的状态,它描述的是一种设计,是程序的结构,比如上面例子里“为每个任务开一个线程”的设计。并发设计和程序实际执行情况并没有直接关联,但是正确的并发设计让并发执行称为可能。反之,如果程序被设计为执行完一个任务再接着执行下一个,那就不是并发设计了,因为做不到并发执行。
之所以并发设计往往需要把流程拆开,是因为如果不拆分也就不可能在同一时间段进行多个任务了。这种拆分可以是平行的拆分,比如抽象成同类的任务,也可以是不平行的,比如分为多个步骤。
在这里插入图片描述
综上,在程序设计层面,并发设计让并发执行成为可能,而并行是并发执行的一种模式。(并行∈并发执行,并发执行=并行+协程)
2. 进程和线程
2.1 常规解释
进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。
线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
2.2 总结
进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程;进程——资源分配的最小单位。
线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。是程序执行的最小单位。
2.3 具体理解
Linux 环境下,每个进程有自己各自独立的大小(4G)的地址空间,大家互不干扰对方,如果两个进程之间通信的话,还需要借助第三方进程间通信工具 Inter-Process Communication, IPC 才能完成(关于进程间通信推荐这篇文章:Linux 下的进程间通信:套接字和信号 | Linux 中国 )。不同的进程通过页表映射,映射到物理内存上各自独立的存储空间,在操作系统的调度下,分别轮流占用 CPU 去运行,互不干扰、互不影响,甚至相互都不知道对方。在每个进程的眼里,CPU 就是他的整个世界,虽然不停地被睡眠,但是一旦恢复运行,一觉醒来,仿佛什么都没发生过一样,认为自己拥有整个 CPU,一直在占有它。
在一个进程中,可能存在多个线程,每个线程类似于合租的每个租客,除了自己的私有空间外,还跟其它线程共享进程的很多资源,如地址空间、全局数据、代码段、打开的文件等等。在线程中,通过各种加锁解锁的同步机制,一样可以用来防止多个线程访问共享资源产生冲突,比如互斥锁、条件变量、读写锁等。
进程具有的特征:
动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
并发性:任何进程都可以同其他进程一起并发执行;
独立性:进程是系统进行资源分配和调度的一个独立单位;
结构性:进程由程序,数据和进程控制块三部分组成
对于操作系统来说,它可以同时运行多个任务。你可以一边听歌,一边打游戏,一边还等着QQ开着语音聊着天,这就是多任务,至少同时有 3 3 3 个任务正在运行。还有很多任务悄悄地在后台同时运行着,只是桌面上没有显示而已。对于过去的单核 CPU,也可以完成这些任务,由于 CPU 执行代码都是顺序执行的,那么,单核 CPU 就轮流让各个任务交替执行,任务 1 1 1 执行 0.01 0.01 0.01 秒,切换到任务 2 2 2,任务 2 2 2 执行 0.01 0.01 0.01 秒,再切换到任务 3 3 3,执行 0.01 0.01 0.01 秒……这样反复执行下去。表面上看,每个任务都是交替执行的,但是,由于 CPU 的执行速度实在是太快了,我们感觉就像所有任务都在同时执行一样。
真正的并行执行多任务只能在多核 CPU 上实现,但是,由于任务数量远远多于 CPU 的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行。
对于操作系统来说,一个任务就是一个进程,比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。
有些进程还不止同时干一件事,比如 Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程。
由于每个进程至少要干一件事,所以,一个进程至少有一个线程。当然,像Word这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。当然,真正地同时执行多线程需要多核CPU才可能实现。
2.4 为什么使用多线程
和进程相比,它是一种非常“节俭”的多任务操作方式。在 Linux 系统中,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护其代码段、堆栈段和数据段,这种多任务工作方式的代价非常“昂贵”。而运行于一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且线程间彼此切换所需要时间也远远小于进程间切换所需要的时间。
线程间方便的通信机制。对不同进程来说它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行。这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其他线程所用,不仅方便,而且快捷。
2.5 进程和线程的区别
什么是进程,什么是线程?
进程是程序一次执行的过程,动态的,进程切换时系统开销大
线程是轻量级进程,切换效率高
进程和线程的空间分配?
每个进程都有独立的0-3G的空间,都参与内核调度,互不影响
同一进程中的线程共享相同的地址空间(共享0-3G)
进程之间和线程之间各自的通信方式
进程间(from Linux 下的进程间通信:套接字和信号 | Linux 中国 ):
共享文件(比如一个进程创建和写入一个文件,然后另一个进程从这个相同的文件中进行读取);
共享内存(使用信号量)(在任何时候当共享内存进入一个写入者场景时,无论是多进程还是多线程,都有遇到基于内存的竞争条件的风险,所以,需要引入信号量来协调(同步)对共享内存的获取);
管道(无名管道、命名管道);
消息队列;
套接字(socket)(套接字的两种类型:IPC 套接字(即 Unix 套接字)给予进程在相同设备(主机)上基于通道的通信能力;网络套接字给予进程运行在不同主机的能力,因此也带来了网络通信的能力。网络套接字需要底层协议的支持,例如 TCP(传输控制协议)或 UDP(用户数据报协议));
信号(信号会中断一个正在执行的程序,在这种意义下,就是用信号与这个程序进行通信。大多数的信号要么可以被忽略(阻塞)或者被处理(通过特别设计的代码)。信号可以在与用户交互的情况下发生。例如,一个用户从命令行中敲了 Ctrl+C 来终止一个从命令行中启动的程序;Ctrl+C 将产生一个 SIGTERM 信号。SIGTERM 意即终止,它可以被阻塞或者被处理,而不像 SIGKILL 信号那样。一个进程也可以通过信号和另一个进程通信,这样使得信号也可以作为一种 IPC 机制)。
线程间:全局变量,信号量,互斥锁
3. C++中的多线程
C++11 标准提供了一个新的线程库,内容包括了管理线程、保护共享数据、线程间的同步操作、低级原子操作(atomic operation)等各种类。
1
2
3
4
5
在这里插入图片描述
那么现在开始吧,看看C++11开始的多线程编程是如何实现的!
3.1 存储持续性-补充
C++11 开始有四种不同的方案来存储数据(新增线程存储),这些方案的区别就在于数据保留在内存中的时间(详情可阅读 Storage-class specifiers):
自动存储持续性:在函数定义中声明的变量(包括函数参数)的存储持续性为自动的。它们在程序开始执行其所属的函数或代码块时被创建,在执行完函数或代码块时,它们使用的内存被释放;
静态存储持续性:在函数定义外定义的变量和使用关键字 static 定义的变量的存储持续性都为静态。它们在程序整个运行过程中都存在;
动态存储持续性:用 new 运算符分配的内存将一直存在,直到使用 delete 运算符将其释放或程序结束为止。这种内存的存储持续性为动态,有时被称为自由存储(free store)或堆(heap);
线程存储持续性:当前,多核处理器很常见,这些 CPU 可同事处理多个执行任务。这让程序能够将计算放在可并行处理的不同线程中。如果变量是使用关键字 thread_local 声明的,则其生命周期与所属的线程一样长。
当声明一个变量 thread_local,那么每个 thread 都有自己的副本,举个例子:
#include
#include
thread_local int i=0;
void f(int newval){
i=newval;
}
void g(){
std::cout< }
void threadfunc(int id){
f(id);
++i;
g();
}
int main(){
i=9;
std::thread t1(threadfunc,1);
std::thread t2(threadfunc,2);
std::thread t3(threadfunc,3);
t1.join();
t2.join();
t3.join();
std::cout< }
输出结果为: 3 3 3、 2 2 2、 4 4 4、 9 9 9(顺序不定)。
4. 从头文件
Header that declares the thread class and the this_thread namespace.
这里说的是头文件 thread,其包含 类 thread 和 命名空间 this_thread。
4.1 线程的 5 种状态
初始化(INIT):该线程正在被创建-----首先申请一个空白的 TCB(Thread Control Block, 线程控制模块,控制着线程的运行和调度),并向 TCB 中填写一些控制和管理进程的信息;然后由系统为该进程分配运行时所必须的资源;最后把该进程转入到就绪状态。
TCB 组成(线程TCB详解):
threadID:线程的唯一标识;
status:线程运行的状态;
register:线程关于 CPU 中寄存器的情况;
PC 程序计数器:线程执行的下一条指令的地址;
优先级:线程在操作系统调度的时候的优先级;
线程的专属存储区:线程单独额存储区域(C++管理数据内存的方式:自动存储、静态存储、动态存储、线程存储);
用户栈:线程执行的用户方法栈,用来保存线程当前执行的用户方法的信息;
内核栈:线程执行的内核方法栈,用来保存线程当前执行的内核方法信息。
就绪(READY):该线程在就绪列表中,等待 CPU 调度。
运行(RUNNING):该线程正在运行。
阻塞(BLOCKED):该线程被阻塞挂起。
BLOCKED 状态包括:PEND(锁、事件、信号量等阻塞)、SUSPEND(主动 PEND)、DELAY(延时阻塞)、PENDTIME(因为锁、事件、信号量事件等超时等待)。
退出(EXIT):该线程运行结束,等待父线程回收其控制块资源。
5. 时间管理
5.1 C语言:time.h
long t0 = time(NULL); // 获取从1970年1月1日到当前时经过的秒数
sleep(3); // 让程序休眠3秒
long t1 = t0 + 3; // 当前时间的三秒后
usleep(3000000); // 让程序休眠3000000微秒,也就是3秒
1
2
3
4
在 C语言中,使用 time(NULL) 获取当前时间,返回的是一个整数long。
其中,使用 sleep() 是让程序休息整数秒;而如果想让程序休息微秒,需使用 usleep()。
这样可以看出,C 语言原始的 API 内,没有类型区分,导致很容易弄错单位,混淆时间点和时间段。
5.2 C++11 时间标准库:std::chrono
因此,从 C++11 开始,就将时间标准化了,它利用 C++ 强类型的特点,明确区分时间点与时间段,明确区分不同的时间单位。
时间点例子:2022年1月8日 13点07分10秒
时间段例子:1分30秒
时间点类型:chrono::steady_clock::time_point 等
时间段类型:chrono::milliseconds,chrono::seconds,chrono::minutes 等
方便的运算符重载:时间点+时间段=时间点,时间点-时间点=时间段
5.2.1 获取时间段 int64_t/double
auto t0 = chrono::steady_clock::now();// 获取当前时间点
auto t1 = t0 + chrono::seconds(30);// 当前时间点的30秒后
auto dt = t1 - t0;// 获取两个时间点的差(时间段)
int64_t sec = chrono::duration_cast
1
2
3
4
举个例子,计算一个步骤花费时间:
#include
#include
int main() {
auto t0 = std::chrono::steady_clock::now();
for (volatile int i = 0; i < 10000000; i++);
auto t1 = std::chrono::steady_clock::now();
auto dt = t1 - t0;
int64_t ms = std::chrono::duration_cast
std::cout << "time elapsed: " << ms << " ms" << std::endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
其中
typedef std::chrono::duration
1
在例子中返回的是一个整数的毫秒数,如果想让精度超过毫秒,可以使用:
using double_ms = std::chrono::duration
1
程序如下:
int main() {
auto t0 = std::chrono::steady_clock::now();
for (volatile int i = 0; i < 10000000; i++);
auto t1 = std::chrono::steady_clock::now();
auto dt = t1 - t0;
using double_ms = std::chrono::duration
double ms = std::chrono::duration_cast
std::cout << "time elapsed: " << ms << " ms" << std::endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
原理为:
duration_cast 可以在任意的 duration 类型之间转换。
duration
R 省略不写就是秒,std::milli 就是毫秒,std::micro 就是微秒
seconds 是 duration
这里我们创建了 double_ms 作为 duration
上面程序的结果可见下图所示,这样得到的结果就有小数点了。
在这里插入图片描述
5.2.2 this_thread
this_thread: this thread
This namespace groups a set of functions that access the current thread.
用于交互当前 thread。需要注意的是 this_thread 是命名空间(函数 get_id 和 yield 定义在 thread 内,而 sleep_until 和 sleep_for在外)。
5.2.2.1 跨平台的 sleep: std::this_thread::sleep_for
std::this_thread::sleep_for: Sleep for time span
Blocks execution of the calling thread during the span of time specified by rel_time.
The execution of the current thread is stopped until at least rel_time has passed from now. Other threads continue their execution.
以前睡眠一段时间,不同操作系统使用不同的API。在 C++11 中可以用 std::this_thread::sleep_for 替代 Unix 类操作系统专有的 usleep。它可以让当前线程休眠一段时间,然后继续。(睡眠一个时间段)
这个 API 单位也可以自己指定,比如在下面示例中,使用 milliseconds 表示毫秒,也可以换成 microseconds 表示微秒,seconds 表示秒,chrono 的强类型让单位选择更自由。
int main() {
std::this_thread::sleep_for(std::chrono::milliseconds(400));
return 0;
}
1
2
3
4
这里就需要提到第四节说的线程的五种状态。线程与进程在使用时非常相似,在计算机中启动的多个线程都需要占用 CPU 资源,但是 CPU 的个数是有限的并且每个 CPU 在同一时间点不可能同时处理多个任务。为了能够实现并发处理,多个线程都是分时复用 CPU 时间片,快速的交替处理各个线程中的任务。因此多个线程之间需要争抢 CPU 时间片,抢到了就执行,抢不到则无法执行(因为默认所有的线程优先级都相同,内核也会从中调度,不会出现某个线程永远抢不到 CPU 时间片的情况)。
在调用 sleep_for 后,这个函数的线程会马上从运行态变成阻塞态并且在这种状态下休眠一定的时长,因为阻塞态的线程已经让出了 CPU 资源,代码也不会被执行,所以线程休眠过程中对 CPU 来说没有任何负担。
5.2.2.2 睡到时间点: std::this_thread::sleep_until
std::this_thread::sleep_until: Sleep until time point
Blocks the calling thread until abs_time.
The execution of the current thread is stopped until at least abs_time, while other threads may continue to advance.
除了接受一个时间段的 sleep_for,还有接受一个时间点的 sleep_until,表示让当前线程休眠直到某个时间点。(睡眠到一个时间点)
在下面这个例程中,与 5.2.2.1 节中直接睡眠 400ms 是等价的。
int main() {
auto t = std::chrono::steady_clock::now() + std::chrono::milliseconds(400);
std::this_thread::sleep_until(t);
return 0;
}
1
2
3
4
5
sleep_until 和 sleep_for 函数功能一样 ,前者基于时间点阻塞 ,后者基于时间段阻塞。
5.2.3 this_thread 内还有些什么(与时间管理无关)
5.2.3.1 get_id()
std::this_thread::get_id: Get thread id
Returns the thread id of the calling thread. This value uniquely identifies the thread.
调用 get_id() 方法可以得到当前线程 ID。
5.2.3.2 yield()
std::this_thread::yield: Yield to other threads
The calling thread yields, offering the implementation the opportunity to reschedule.
This function shall be called when a thread waits for other threads to advance without blocking.
yield() 能主动由运行态退让出已经抢到的时间片,并转为就绪态,这样其他线程就能抢到 CPU 时间片。线程调用了 yield() 会主动放弃 CPU 资源,注意之后这个变为就绪态的线程会马上参与到下一轮 CPU 的抢夺战中,不排除它能继续抢到 CPU 时间片的情况。
比如以下例子:
void func() {
for (size_t i = 0; i < 10000000; ++i){
std::this_thread::yield();
std::cout << "子线程ID:" << std::this_thread::get_id() << ",i = " << i << std::endl;
}
}
int main() {
std::cout << "主线程ID:" << std::this_thread::get_id() << std::endl;
std::thread t1(func);
std::thread t2(func);
t1.join();
t1.join();
return 0;
}
func() 中的 for 循环会占用大量时间。在极端情况下,如果当前线程占用 CPU 资源不释放就会导致其他线程中的任务无法被处理,或者该线程每次都能抢到 CPU 时间片,导致其他线程中的任务没有机会被执行。解决方案就是每执行一次循环,让该线程主动放弃 CPU 资源,重新和其他线程再次抢夺 CPU 时间片,如果其他线程抢到了 CPU 时间片那么抢到时间片的线程就可以执行相应的任务了。
yield() 总结:
yield() 的目的是避免一个线程长时间占用 CPU 资源,从而多线程处理能力下降;
yield() 是让当前线程主动放弃自己抢到的 CPU 资源,但是在下一轮还会继续抢。
6. 线程
6.1 为什么需要多线程:无阻塞多任务
我们的一个独立程序常常需要同时处理多个任务。例如:后台在执行一个很耗时的任务,比如下载一个文件,同时还要和用户交互。这在 GUI 应用程序中很常见,比如浏览器在后台下载文件的同时,用户仍然可以用鼠标操作其 UI 界面。
现在来看下面例子:
#include
#include
#include
void download(std::string file) {
for (int i = 0; i < 10; i++) {
std::cout << "Downloading " << file
<< " (" << i * 10 << "%)..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(400));
}
std::cout << "Download complete: " << file << std::endl;
}
void interact() {
std::string name;
std::cin >> name;
std::cout << "Hi, " << name << std::endl;
}
int main() {
download("hello.zip");
interact();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在这个程序内,如果没有多线程,就必须等文件下载完了才能继续和用户Say Hi。下载完成前,整个界面都会处于“未响应”状态,用户想做别的事情就做不了。
在这里插入图片描述
6.2 现代 C++ 中的多线程:std::thread
老版本的 C 语言有
void download(std::string file) {
for (int i = 0; i < 10; i++) {
std::cout << "Downloading " << file
<< " (" << i * 10 << "%)..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(400));
}
std::cout << "Download complete: " << file << std::endl;
}
void interact() {
std::string name;
std::cin >> name;
std::cout << "Hi, " << name << std::endl;
}
int main() {
std::thread t1([&] {
download("hello.zip");
});
interact();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
当直接编译这个代码时,会发现在链接时会出现问题。这是因为 std::thread 的实现背后是基于 pthread 的。需要在 CMakeLists.txt 里链接 Threads::Threads。
cmake_minimum_required(VERSION 3.10)
set(CMAKE_CXX_STANDARD 17)
project(cpptest LANGUAGES CXX)
add_executable(cpptest main.cpp)
find_package(Threads REQUIRED)
target_link_libraries(cpptest PUBLIC Threads::Threads)
1
2
3
4
5
6
7
8
9
10
6.3 主线程等待子线程结束:t1.join()
std::thread::join: Join thread
The function returns when the thread execution has completed.
This synchronizes the moment this function returns with the completion of all the operations in the thread: This blocks the execution of the thread that calls this function until the function called on construction returns (if it hasn’t yet).
After a call to this function, the thread object becomes non-joinable and can be destroyed safely.
现在已经有多线程了,文件下载和用户交互分别在两个线程,同时独立运行。从而下载过程中也可以响应用户请求,提升了体验。
这时运行上面的程序,会发现一个问题:在输入完 ling 以后,程序的确及时地和我交互了。但是用户交互所在的主线程退出后,文件下载所在的子线程,因为从属于这个主线程,也被迫退出了。
在这里插入图片描述
因此,我们想要让主线程不要急着退出,等子线程也结束了再退出。可以用 std::thread 类的成员函数 join() 来等待刚刚创建的t1线程结束。
int main() {
std::thread t1([&] {
download("hello.zip");
});
interact();
std::cout << "Waiting for child thread..." << std::endl;
t1.join();
std::cout << "Child thread exited!" << std::endl;
return 0;
}
1
2
3
4
5
6
7
8
9
10
在子线程对象中调用 join() 函数,调用此函数的线程会被阻塞 ,但是子线程对象中的任务函数会继续执行 ,当任务执行完毕之后 join() 函数会清理当前子线程中的相关资源后返回,同时该线程函数会解除阻塞继续执行下去。函数在哪个线程中被执行,函数就阻塞那个函数。如果要阻塞主线程的执行,只需要在主线程中通过子线程对象调用这个方法即可,当调用这个方法的子线程对象中的任务函数执行完毕之后,主线程的阻塞也就随之解除了。
在上面的例子中,当主线程运行到 t1.join(),根据子线程对象 t1 的任务函数的执行情况,主线程会:
任务函数还没执行完毕,主线程阻塞直到任务执行完毕,主线程解除阻塞,继续向下执行;
任务函数已执行完毕,主线程不会阻塞,继续向下运行。
————————————————
版权声明:本文为CSDN博主「泠山」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_28087491/article/details/127464635