超线程技术
概述
- 现代
CPU
通常确实在物理核心的基础上引入逻辑核心,例如通过超线程(Hyper-Threading
)技术,一个物理核心能够处理两个线程- 使得操作系统和应用程序认为该
CPU
拥有的核心数量翻倍
- 使得操作系统和应用程序认为该
- 不过,
CPU
的逻辑核心和线程的概念在软件开发中存在一些区别- 逻辑核心更偏向于物理硬件资源的虚拟化
- 而线程在编程中是对操作系统和CPU资源的一种使用方式,意味着在软件中能执行的独立指令序列
逻辑核心和线程的理解
- 逻辑核心确实属于
CPU
硬件的处理能力 范畴,通过技术(如超线程)让物理核心同时运行多个任务 - 线程则是操作系统用来 组织和管理程序并发 的基本单位
- 操作系统会将不同的线程分配到不同的逻辑核心上执行
线程
概述
- 线程是程序的基本执行单元,是调度和分配
CPU
时间的基本实体 - 一个程序可以包含一个或多个线程,每个线程独立执行
状态
- 新建状态(
New
):线程被创建但未开始运行 - 就绪状态(
Ready
):线程准备好执行,等待操作系统分配CPU
- 运行状态(
Running
):线程被CPU
调度器分配时间片,正在执行 - 阻塞状态(
Blocked/Waiting
):线程因等待资源或事件而暂停执行,不占用CPU
- 终止状态(
Terminated
):线程完成了执行或因异常终止
线程如何向 CPU
提出执行请求
- 就绪队列
- 当线程创建并进入就绪状态时,它会被放入操作系统的就绪队列中
- 就绪队列中的线程表示它们已经准备好执行,等待
CPU
分配时间片
- 调度请求
- 线程向
CPU
提出执行请求的本质是被操作系统的调度器从就绪队列中选择并分配CPU
时间片 - 线程本身不会主动请求
CPU
,而是由调度器根据调度算法(如轮询调度、优先级调度等)来决定哪个线程可以执行
- 线程向
- 线程的状态转换
- 就绪到运行:当调度器选择某个线程执行时,线程从就绪状态切换到运行状态
- 运行到阻塞:线程因等待
I/O
操作、锁定资源或等待事件进入阻塞状态 - 阻塞到就绪:当等待条件满足(如
I/O
完成、资源释放),线程返回就绪队列等待重新调度
CPU
如何响应线程的执行请求
CPU
调度器CPU
调度器是操作系统内核的一部分,负责在就绪队列中选择线程,将CPU
时间片分配给它们
- 时间片
- 每个线程在被调度到
CPU
上时会分配一个时间片 - 时间片结束后,线程会被挂起,切换到另一个线程
- 通过时间片轮转,
CPU
可以在多个线程之间快速切换,形成线程“并发”执行的效果
- 每个线程在被调度到
- 中断和抢占
CPU
调度依赖于时钟中断,时钟中断是调度器抢占正在运行的线程并选择下一个线程执行的时机- 抢占机制确保高优先级的线程可以优先执行,即使它们在低优先级线程的时间片内出现
调度器的工作机制
- 上下文切换(
Context Switch
) - 当调度器决定让某个线程运行时,会执行上下文切换
- 上下文切换包括保存当前运行线程的状态(寄存器、程序计数器等),然后加载即将运行的线程的状态
理解
- 程序
A
的线程可以被CPU
的任何一个逻辑核心执行,具体由操作系统的调度器决定 - 而任何一个逻辑核心在执行某个线程时,都可以通过线程本地存储(
Thread Local Storage
,TLS
)和线程的上下文信息来加载并恢复该线程的最后一次执行状态,从而继续执行之前未完成的任务 - 总体来讲,每个逻辑核心虽然任何时刻只能执行一个线程,但由于时间片分配非常短(通常在几毫秒级别),逻辑核心会很快得频繁切换执行不同的线程,使得我们感知不到这个交替过程
CPU 执行线程的详细过程
- 线程上下文保存与恢复
- 当一个线程的时间片结束或被调度器挂起时,操作系统会保存该线程的上下文信息
包括:
寄存器状态(如通用寄存器、程序计数器、栈指针等)
线程本地存储(TLS
):用于存储线程特定的数据
线程栈:保存函数调用、局部变量、返回地址等 - 当线程被重新调度时,这些保存的上下文会被加载到
CPU
的寄存器中,使得线程可以从上次暂停的位置继续执行
- 当一个线程的时间片结束或被调度器挂起时,操作系统会保存该线程的上下文信息
- 逻辑核心执行线程的机制
- 当调度器决定让一个线程在某个逻辑核心上运行时,
CPU
会通过上下文切换机制恢复线程的执行状态 CPU
将寄存器恢复到该线程最后一次保存的状态,包括程序计数器(PC
)的位置,这个计数器指向下一条待执行的指令地址
- 当调度器决定让一个线程在某个逻辑核心上运行时,
- 指令的获取和执行
- 取指令,如下:
- 程序计数器中的值通过 地址总线 被发送到系统的内存控制器
(通常是RAM
中的某个物理地址,也就是程序的代码段) - 内存控制器接收地址请求后,通过 数据总线 返回该地址处存储的指令(通常是机器码形式的指令)
- 指令的解码与执行
- 解码(
Decode
)
CPU
解析指令,确定要执行的操作类型(例如算术运算、逻辑运算、内存访问等) - 执行(
Execute
)
CPU
执行指令,完成指令中指定的操作(如寄存器之间的数据传输、内存读写、算术逻辑运算等)
- 解码(
- 指令结果的处理
- 执行结果可能会更新
CPU
的寄存器、改变内存中的数据,或者修改程序计数器的值以指向下一条指令
- 执行结果可能会更新
- 循环重复执行
- 这一过程会不断重复:取指令、解码、执行、更新,直到时间片结束或线程被中断
- 总结:
- 当调度器决定让一个线程在某个逻辑核心上运行时,
CPU
会通过上下文切换机制恢复线程的执行状态 - 然后,程序计数器中的值通过 地址总线 被发送到系统的内存控制器
- 内存控制器接收地址请求后,通过 数据总线 返回该地址处存储的指令
- 在指令通过数据总线传输到
CPU
之前,内存控制器会发出一个“数据就绪”信号,告知CPU
数据已经准备好 - 在
CPU
接收到“数据就绪”信号之前,可能会进入短暂的等待状态,确保不会在数据未传输完成时开始操作 - 一旦数据总线上的指令准备好,
CPU
会锁存(Latch
)这些数据到内部指令寄存器(Instruction Register
,IR
)中 CPU
的时钟信号与控制总线的“数据就绪”信号同步,以确保数据被正确接收CPU
会检查控制信号的状态,以确定数据传输是否成功,例如通过错误检测信号(如奇偶校验、ECC
检查)来确保数据完整性CPU
将指令从指令寄存器中读取,并通过解码单元解析该指令- 根据解码结果,执行相应的操作(如算术、逻辑、内存访问等)
- 当调度器决定让一个线程在某个逻辑核心上运行时,
协程
概述
- 与线程不同,协程是一种 更加轻量级的并发执行单位
- 协程通常不依赖
CPU
的物理或逻辑核心,而是依赖于 用户代码控制的上下文切换,通过在特定位置暂停和恢复执行来实现
原理
- 协作式切换
- 协程的切换由代码中显式的指令控制(如
yield
或await
),不会被抢占 - 协程可以在一段逻辑执行完成后,交还控制权,而线程则通常依赖操作系统的调度器来决定什么时候被暂停或恢复
- 协程的切换由代码中显式的指令控制(如
- 不占用系统线程
- 协程在同一线程中切换执行,因此可以在不增加线程负担的前提下实现并发
- 而线程是由操作系统调度的实体,会占用
CPU
资源
- 更轻量的资源开销
- 由于不依赖系统调用,协程的创建和切换速度远高于线程,且由于不会频繁切换
CPU
核心,也更节省内存
- 由于不依赖系统调用,协程的创建和切换速度远高于线程,且由于不会频繁切换
- 适用于I/O密集型任务
- 协程常用于高并发
I/O
密集的场景(例如网络请求、文件读取),能够通过在等待时切换协程来提高效率,但它们通常不适合CPU
密集型任务
- 协程常用于高并发
理解
- 线程 是由操作系统管理的并发执行单元,属于 系统级别 的资源,由操作系统内核调度和分配,直接与
CPU
的物理或逻辑核心绑定 - 协程 则是在 程序员级别 管理的,属于用户空间的轻量级并发机制
- 协程的调度是由程序员通过代码显式控制的,不依赖操作系统的调度器
- 可以把协程理解为一种 对线程的封装或使用,因为协程一般会在某个线程上运行,并通过用户代码在这个线程内部切换执行的任务
总结1
- 协程共享同一线程
- 多个协程运行在同一个线程内,因此不需要额外的线程创建、销毁开销,也不会涉及线程切换的上下文切换
- 协作式调度
- 协程的执行顺序是由代码显式控制的(例如通过
await
或yield
等关键字),不会被操作系统强制打断 - 协程可以在一个任务暂时不需要继续时(比如等待
I/O
操作完成)主动让出控制权,从而让其他协程在同一个线程上执行
- 协程的执行顺序是由代码显式控制的(例如通过
- 适合高并发
I/O
任务- 由于切换开销小,协程特别适合高并发的 I/O 密集型任务(例如网络请求、文件读写等)
- 当一个协程等待时,另一个协程可以马上执行,从而提高资源利用率
- 不适合
CPU
密集任务- 协程一般在单线程环境中执行,
CPU
密集型任务会占满该线程的计算资源,阻塞其他协程的执行
- 协程一般在单线程环境中执行,
总结1示例
- 在一个简单的应用中,假设有一个线程上运行着三个协程
- 协程
A
:负责下载文件 - 协程
B
:处理用户输入 - 协程
C
:写入日志文件
- 协程
- 在下载、读取输入或写入时,任何协程如果遇到等待,就可以主动让出控制权,允许其他协程运行
总结2
- 协程的切换完全发生在 线程内部,所以对于
CPU
来说,这个线程看起来与普通线程并无不同- 从
CPU
的角度来看,执行的指令流就是线程级别的 CPU
只需要根据线程的上下文继续执行指令,而至于这些指令是属于协程A
的任务还是协程B
的任务,CPU
并不关心,操作系统也不干预
- 从
- 对于协程来说,协程的切换由程序中的代码逻辑(例如
yield
或await
)控制- 协程可以在特定的等待点让出控制权给同线程的其他协程
- 这种切换是用户级的,也就是由程序自行管理,不涉及
CPU
和操作系统的调度
总结2示例
- 在一个简单的应用中,假设有一个线程上运行着三个协程
- 协程
A
:负责下载文件 - 协程
B
:处理用户输入 - 协程
C
:写入日志文件
- 协程
- 在协程的模型中,这些任务(协程
A
、B
、C
)的调度和切换都是由程序员控制的,因此可以灵活地按照需求来安排它们的执行方式- 可以设定一个循环,每隔一段时间轮流执行
ABC
,比如先执行一些下载任务(A
),然后检查用户输入(B
),最后将产生的数据写入日志(C
) - 可以在一次操作中完全完成
A
,再进入B
,最后进入C
。比如,下载一个文件后才处理用户输入,然后再写日志 - 可以根据需求随时在
A
和B
之间切换,并在需要时执行C
。比如,当A
或B
有等待操作(如网络延迟或用户未输入)时,立即切换到其他任务执行
- 可以设定一个循环,每隔一段时间轮流执行
示例代码
- 模拟一些任务
- 任务
A
:下载文件(模拟为延迟操作) - 任务
B
:处理用户输入(模拟为打印输出) - 任务
C
:写入日志(模拟为打印日志内容) - 为了简单起见,示例代码使用
std::chrono::milliseconds
模拟延迟,以便切换任务
- 任务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
#include <iostream> #include <coroutine> #include <chrono> #include <thread> #include <memory> // 定义一个简单的Awaitable结构,用于延迟协程的执行 struct Timer { std::chrono::milliseconds duration; bool await_ready() const noexcept { return false; } void await_suspend(std::coroutine_handle<> handle) const { std::thread([=]() { std::this_thread::sleep_for(duration); handle.resume(); }).detach(); } void await_resume() const noexcept {} }; // 定义一个任务结构来表示协程的任务 struct Task { struct promise_type { Task get_return_object() { return {}; } std::suspend_never initial_suspend() { return {}; } std::suspend_never final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() { std::terminate(); } }; }; // 模拟任务A:下载文件 Task taskA() { std::cout << "任务A:开始下载文件...\n"; co_await Timer{std::chrono::milliseconds(1000)}; // 模拟下载延迟 std::cout << "任务A:文件下载完成!\n"; } // 模拟任务B:处理用户输入 Task taskB() { std::cout << "任务B:等待用户输入...\n"; co_await Timer{std::chrono::milliseconds(500)}; // 模拟用户输入延迟 std::cout << "任务B:用户输入处理完成!\n"; } // 模拟任务C:写入日志 Task taskC() { std::cout << "任务C:写入日志...\n"; co_await Timer{std::chrono::milliseconds(200)}; // 模拟日志写入延迟 std::cout << "任务C:日志写入完成!\n"; } // 主调度函数,循环执行任务ABC Task scheduler() { while (true) { co_await taskA(); // 执行任务A co_await taskB(); // 执行任务B co_await taskC(); // 执行任务C } } int main() { scheduler(); // 启动调度 std::this_thread::sleep_for(std::chrono::seconds(5)); // 让主线程保持一段时间以观察输出 return 0; } |
- 代码解析如下:
- 为什么上面的
Timer
是co_await
类型Timer
之所以能被co_await
,是因为它符合协程机制中awaitable
的要求- 为了让一个对象可以使用
co_await
操作符,C++20
协程的设计中规定了该对象需要实现特定的接口或语义 Timer
就是这样一个“可等待对象”(awaitable
),它实现了协程等待的必要接口,因此可以被co_await
使用
Timer
如何成为awaitable
- 在
C++20
协程模型中,一个对象要成为awaitable
类型,通常需要实现以下三个方法: await_ready
:返回bool
类型,用于判断是否已经准备好
如果返回true
,则协程不暂停,继续执行
如果返回false
,协程会暂停等待await_suspend
:在协程挂起时被调用
它接受一个std::coroutine_handle
参数,这个句柄表示当前协程的句柄,可以在挂起时保存以便之后恢复
在Timer
中,我们通过std::this_thread::sleep_for
模拟等待一段时间后调用handle.resume()
,恢复协程await_resume
:当协程恢复执行时调用,用于返回等待操作的结果
这里我们不需要返回特定结果,因此它只是一个空函数
- 在
co_await
co_await
并不是一种类型,而是一种 操作符,用来在协程中表示“等待”操作- 它的作用是使得协程在执行到这个点时 可以暂停执行,等待某个条件达成后再恢复
- 协程任务对象
- 在
C++20
协程中,任务的返回类型需要一个promise type
,这个类型是协程的状态和结果的承载体 promise_type
必须实现特定的方法,以定义协程的行为get_return_object()
:这个方法返回协程的返回对象(通常是一个Task
对象)
当协程完成时,这个返回对象将被返回给调用者initial_suspend()
:这个方法返回一个对象,表示协程开始时是否要挂起
返回std::suspend_always
表示协程一开始就挂起,返回std::suspend_never
表示立即开始执行final_suspend()
:这个方法在协程完成时被调用,返回的对象决定了协程结束后是否要挂起
一般来说,返回std::suspend_always
可以让协程在结束时仍然保持状态,便于后续处理return_void()
:用于处理无返回值的协程,表示协程正常结束unhandled_exception()
:处理未捕获异常的方式。通常可以选择终止程序或以其他方式处理异常
- 在
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
struct Task { struct promise_type { Task get_return_object() { return {}; // 返回 Task 对象 } std::suspend_never initial_suspend() { return {}; // 协程立即开始执行 } std::suspend_always final_suspend() noexcept { return {}; // 协程结束时挂起 } void return_void() {} // 无返回值的协程结束 void unhandled_exception() { std::terminate(); } // 处理异常 }; }; |
本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Spdlog记述:三07/23
- ♥ Boost程序库完全开发指南:时间与内存08/21
- ♥ Bkwin一12/01
- ♥ Windows 窗口以及渲染相关06/15
- ♥ WindowsETW进程监控相关03/17
- ♥ C++并发编程_概念了解05/07