开始之前,分享今天于哥说的笑话~

在实际测试环境,发现某个地方慢,打日志看为什么慢,后来发现是最开始打的日志造成的问题。【套娃了】

进程

计算机的核心是CPU,它承担了所有的计算任务;而操作系统是计算机的管理者,它负责任务的调度、资源的分配和管理,统领整个计算机硬件;应用程序则是具有某种功能的程序,程序是运行于操作系统之上的。

进程一般由程序、数据集合和进程控制块三部分组成。

  • 程序用于描述进程要完成的功能,是控制进程执行的指令集;
  • 数据集合是程序在执行时所需要的数据和工作区;
  • 程序控制块(Program Control Block,简称PCB),包含进程的描述信息和控制信息,是进程存在的唯一标志。

补充:细聊PCB

在Linux中,PCB结构为task_struct;

task_struct是Linux内核的一种数据结构,它会被装载到RAM里并且包含进程的信息,每个进程都把它的信息放在task_struct这个数据结构里。

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
struct task_struct
{
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
/*
   表示进程的当前状态:
TASK_RUNNING:正在运行或在就绪队列run-queue中准备运行的进程,实际参与进程调度。
TASK_INTERRUPTIBLE:处于等待队列中的进程,待资源有效时唤醒,也可由其它进程通过信号(signal)或定时中断唤醒后进入就绪队列run-queue。
TASK_UNINTERRUPTIBLE:处于等待队列中的进程,待资源有效时唤醒,不可由其它进程通过信号(signal)或定时中断唤醒。
TASK_ZOMBIE:表示进程结束但尚未消亡的一种状态(僵死状态)。此时,进程已经结束运行且释放大部分资源,但尚未释放进程控制块。
TASK_STOPPED:进程被暂停,通过其它进程的信号才能唤醒。导致这种状态的原因有二,或者是对收到SIGSTOP、SIGSTP、SIGTTIN或SIGTTOU信号的反应,或者是受其它进程的ptrace系统调用的控制而暂时将CPU交给控制进程。
TASK_SWAPPING: 进程页面被交换出内存的进程。
*/
unsigned long flags;  //进程标志,与管理有关,在调用fork()时给出
int sigpending;     //进程上是否有待处理的信号
mm_segment_t addr_limit;   //进程地址空间,区分内核进程与普通进程在内存存放的位置不同
/*用户线程空间地址: 0..0xBFFFFFFF。
内核线程空间地址: 0..0xFFFFFFFF */

struct exec_domain *exec_domain;  //进程执行域
volatile long need_resched;     //调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
unsigned long ptrace;
int lock_depth;  //锁深度
long counter;   //进程的基本时间片,在轮转法调度时表示进程当前还可运行多久,在进程开始运行是被赋为priority的值,以后每隔一个tick(时钟中断)递减1,减到0时引起新一轮调 度。重新调度将从run_queue队列选出counter值最大的就绪进程并给予CPU使用权,因此counter起到了进程的动态优先级的作用
long nice;     //静态优先级
unsigned long policy;  //进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR,分时进程:SCHED_OTHER
//在Linux 中, 采用按需分页的策略解决进程的内存需求。task_struct的数据成员mm 指向关于存储管理的mm_struct结构。
struct mm_struct *mm;  //进程内存管理信息
int has_cpu, processor;
unsigned long cpus_allowed;
struct list_head run_list;  //指向运行队列的指针
unsigned long sleep_time;   //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表,其根是init_task
//在Linux 中所有进程(以PCB 的形式)组成一个双向链表,next_task和prev_task是链表的前后向指针
  struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm; //active_mm 指向活动地址空间。
struct linux_binfmt *binfmt;  //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal;    //父进程终止是向子进程发送的信号
unsigned long personality;
int dumpable:1;
int did_exec:1;
pid_t pid;   //进程标识符,用来代表一个进程
pid_t pgrp;  //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp;   //进程控制终端所在的组标识
pid_t session;      //进程的会话标识
pid_t tgid;
int leader;        //表示进程是否为会话主管
<br>  //指向最原始的进程任务指针,父进程任务指针,子进程任务指针,新兄弟进程任务指针,旧兄弟进程任务指针。
struct task_struct *p_opptr, *p_pptr, *p_cptr, *p_ysptr, *p_osptr;
struct list_head thread_group;   //线程链表
<br>  //用于将进程链入HASH表,系统进程除了链入双向链表外,还被加入到hash表中
struct task_struct *pidhash_next;
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit;   //供wait4()使用
struct semaphore *vfork_sem;     //供vfork()使用
unsigned long rt_priority;      //实时优先级,用它计算实时进程调度时的weight值
<br>   //it_real_value,it_real_incr用于REAL定时器,单位为jiffies,系统根据it_real_value
//设置定时器的第一个终止时间.在定时器到期时,向进程发送SIGALRM信号,同时根据
//it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。
//当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送信号SIGPROF,并根据it_prof_incr重置时间.
//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种
//状态下,每个tick都使it_virt_value值减一当减到0时,向进程发送信号SIGVTALRM,根据it_virt_incr重置初值
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_incr;
struct timer_list real_timer;  //指向实时定时器的指针
struct tms times;         //记录进程消耗的时间
unsigned long start_time;    //进程创建的时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];//记录进程在每个CPU上所消耗的用户态时间和核心态时间
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copyon Write页和匿名页)和主缺页数(从映射文件或交换
//设备读入的页面数);nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt,cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。
//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable:1;   //表示进程的虚拟地址空间是否允许换出
//进程认证信息
//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid,euid,egid为有效uid,gid
//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件系统的访问权限时使用他们。
//suid,sgid为备份uid,gid
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
int ngroups;     //记录进程在多少个用户组中
gid_t groups[NGROUPS];  //记录进程所在的组
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;//进程的权能,分别是有效位集合,继承位集合,允许位集合
int keep_capabilities:1;
struct user_struct *user;  //代表进程所属的用户
struct rlimit rlim[RLIM_NLIMITS];   //与进程相关的资源限制信息
unsigned short used_math;   //是否使用FPU
char comm[16];     //进程正在运行的可执行文件名
//文件系统信息
int link_count;
struct tty_struct *tty;  //进程所在的控制终端,如果不需要控制终端,则该指针为空
unsigned int locks; /* How many file locks are being held */
//进程间通信信息
struct sem_undo *semundo;  //进程在信号量上的所有undo操作
struct sem_queue *semsleeping;  //当进程因为信号量操作而挂起时,他在该队列中记录等待的操作
struct thread_struct thread;   //进程的CPU状态,切换时,要保存到停止进程的task_struct中
struct fs_struct *fs;     //文件系统信息,fs保存了进程本身与VFS(虚拟文件系统)的关系信息
struct files_struct *files; //打开文件信息
//信号处理函数
spinlock_t sigmask_lock; /* Protects signal and blocked */
struct signal_struct *sig; //信号处理函数
sigset_t blocked;      //进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; //进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
/* Thread group tracking */
u32 parent_exec_id;
u32 self_exec_id;
spinlock_t alloc_lock; //用于申请空间时用的自旋锁。自旋锁的主要功能是临界区保护
};
  1. 进程标识信息
    • 进程ID(Process ID, PID):唯一标识一个进程的标识符。
    • 父进程ID:创建当前进程的父进程的ID。
  2. 处理器状态信息
    • 程序计数器(Program Counter, PC):指示下一条将要执行的指令的地址。
    • CPU寄存器的内容:包括通用寄存器、浮点寄存器、状态寄存器等,保存进程的执行状态。
    • 进程状态:如运行、就绪、阻塞等状态。
  3. 内存管理信息
    • 基地址和限长寄存器:用于定义进程的地址空间范围。
    • 页表或段表:用于虚拟内存管理,映射虚拟地址到物理地址。
  4. 调度和状态信息
    • 进程优先级:用于进程调度。
    • 调度队列指针:指向进程在调度队列中的位置,用于进程调度。
    • 其他调度参数:如时间片长度、剩余时间等。
  5. 进程间通信信息
    • 信号量、消息队列:用于进程间通信和同步。
    • 管道和共享内存:用于进程间数据传输。
  6. I/O状态信息
    • 打开的文件列表:进程当前打开的所有文件的列表。
    • I/O设备信息:如进程使用的I/O设备、设备状态等。
  7. 汇总信息
    • CPU使用时间:进程使用的CPU时间。
    • 内存使用量:进程使用的内存量。
    • 其他资源使用信息:如I/O操作次数等。

线程

随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程。

补充:进程切换流程

这里我举个例子说明进程A切换到进程B的例子:

  1. 保存进程A的上下文
    • 将进程A的程序计数器、通用寄存器、状态寄存器等保存到进程A的PCB中。
    • 保存进程A的页表基地址、段表基地址到PCB中。
    • 保存进程A的文件描述符表、信号处理信息等。
  2. 更新进程A的状态
    • 将进程A的状态从“运行”改为“就绪”或“阻塞”。
  3. 调度器选择进程B
    • 调度器根据调度算法选择进程B。
    • 将进程B的状态从“就绪”改为“运行”。
  4. 加载进程B的上下文
    • 从进程B的PCB中恢复程序计数器、通用寄存器、状态寄存器等内容。
    • 设置页表基地址和段表基地址为进程B的地址空间。
    • 恢复进程B的文件描述符表、信号处理信息等。
  5. 切换到进程B并执行
    • 更新程序计数器为进程B的下一条指令地址。
    • CPU开始执行进程B的代码。

线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。

为了理解线程和线程在内存的布局,

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
进程A
├── 进程控制块(PCB)
│ ├── 进程ID(PID)
│ ├── 进程状态
│ ├── 调度信息
│ └── 其他元数据
├── 内存空间
│ ├── 代码段
│ ├── 数据段
│ ├── 堆
│ └── 共享资源
│ ├── 文件描述符
│ └── 信号处理信息
├── 线程1
│ ├── 线程控制块(TCB)
│ │ ├── 线程ID(TID)
│ │ ├── 线程状态
│ │ ├── 寄存器内容
│ │ └── 程序计数器
│ ├── 独立栈
│ └── 共享资源(与进程共享)
├── 线程2
│ ├── 线程控制块(TCB)
│ │ ├── 线程ID(TID)
│ │ ├── 线程状态
│ │ ├── 寄存器内容
│ │ └── 程序计数器
│ ├── 独立栈
│ └── 共享资源(与进程共享)

线程(Thread)

  • 线程控制块(TCB):保存线程的元数据,如线程ID、状态、寄存器内容、程序计数器等。
  • 独立栈:每个线程都有自己的栈,用于存储局部变量和函数调用信息。
  • 共享资源:与进程内其他线程共享代码段、数据段、堆和文件描述符等资源。

因此,我们就知道线程上下文切换比进程上下文切换要快

原因:

  1. 资源共享
    • 线程:线程共享同一进程的内存地址空间、文件描述符和其他资源,只需要切换寄存器和程序计数器等上下文信息。
    • 进程:进程切换需要切换整个内存地址空间,包括页表和其他资源,涉及更多的状态保存和恢复。
  2. 缓存效率
    • 线程:由于线程共享同一进程的内存空间,切换时不会导致缓存(如CPU缓存和TLB)的显著失效,提高了缓存命中率。
    • 进程:进程切换通常会导致缓存失效,需要重新加载缓存数据,增加了额外的开销。
  3. 模式切换
    • 线程:线程切换通常在用户态完成,不涉及内核态和用户态的频繁切换。
    • 进程:进程切换需要在内核态和用户态之间切换,涉及更多的特权级别改变和指令开销。

总结

简单来说:进程切换需要切换整个地址空间,包括页表切换和缓存刷新等,还需要保存进程的上下文信息,包括 CPU 寄存器状态和栈指针。但是,同一进程内的线程共享内存地址空间,只需要切换上下文。

任务调度

在一个进程中,当一个线程任务执行几毫秒后,会由操作系统的内核(负责管理各个任务)进行调度,通过硬件的计数器中断处理器,让该线程强制暂停并将该线程的寄存器放入内存中,通过查看线程列表决定接下来执行哪一个线程,并从内存中恢复该线程的寄存器,最后恢复该线程的执行,从而去执行下一个任务。

上述过程中,任务执行的那一小段时间叫做时间片,任务正在执行时的状态叫运行状态,被暂停的线程任务状态叫做就绪状态,意为等待下一个属于它的时间片的到来。

这种方式保证了每个线程轮流执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发(别觉得并发有多高深,它的实现很复杂,但它的概念很简单,就是一句话:多个任务同时执行)。多任务运行过程的示意图如下:

image-20240629160358544

多线程与多核

上面提到的时间片轮转的调度方式说一个任务执行一小段时间后强制暂停去执行下一个任务,每个任务轮流执行。很多操作系统的书都说“同一时间点只有一个任务在执行”。那有人可能就要问双核处理器呢?难道两个核不是同时运行吗?

其实“同一时间点只有一个任务在执行”这句话是不准确的,至少它是不全面的。那多核处理器的情况下,线程是怎样执行呢?这就需要了解内核线程。

多核(心)处理器是指在一个处理器上集成多个运算核心从而提高计算能力,也就是有多个真正并行计算的处理核心,每一个处理核心对应一个内核线程。
内核线程(Kernel Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。一般一个处理核心对应一个内核线程,比如单核处理器对应一个内核线程,双核处理器对应两个内核线程,四核处理器对应四个内核线程。

现在的电脑一般是双核四线程、四核八线程,是采用超线程技术将一个物理处理核心模拟成两个逻辑处理核心,对应两个内核线程,所以在操作系统中看到的CPU数量是实际物理CPU数量的两倍,如你的电脑是双核四线程,打开“任务管理器\性能”可以看到4个CPU的监视器,四核八线程可以看到8个CPU的监视器。

image-20240629162446104

超线程技术就是利用特殊的硬件指令,把一个物理芯片模拟成两个逻辑处理核心,让单个处理器都能使用线程级并行计算,进而兼容多线程操作系统和软件,减少了CPU的闲置时间,提高的CPU的运行效率。这种超线程技术(如双核四线程)由处理器硬件的决定,同时也需要操作系统的支持才能在计算机中表现出来。【超线程技术通过在每个物理核心上模拟多个逻辑处理器,使得核心能够同时处理多个线程,最大化资源利用,提高并行处理能力和整体性能。】

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Lightweight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,也被叫做用户线程。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。用户线程与内核线程的对应关系有三种模型:一对一模型、多对一模型、多对多模型,在这以4个内核线程、3个用户线程为例对三种模型进行说明。

一对一模型

对于一对一模型来说,一个用户线程就唯一地对应一个内核线程(反过来不一定成立,一个内核线程不一定有对应的用户线程)。这样,如果CPU没有采用超线程技术(如四核四线程的计算机),一个用户线程就唯一地映射到一个物理CPU的内核线程,线程之间的并发是真正的并发。一对一模型使用户线程具有与内核线程一样的优点,一个线程因某种原因阻塞时其他线程的执行不受影响;此处,一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。

但一对一模型也有两个缺点:

  1. 许多操作系统限制了内核线程的数量,因此一对一模型会使用户线程的数量受到限制;
  2. 许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。

image-20240629163102594

多对一模型

多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,系统内核感受不到线程的实现方式。用户线程的建立、同步、销毁等都在用户态中完成,不需要内核的介入。因此相对一对一模型,多对一模型的线程上下文切换速度要快许多;此外,多对一模型对用户线程的数量几乎无限制。

但多对一模型也有两个缺点:

  1. 如果其中一个用户线程阻塞,那么其它所有线程都将无法执行,因为此时内核线程也随之阻塞了;
  2. 在多处理器系统上,处理器数量的增加对多对一模型的线程性能不会有明显的增加,因为所有的用户线程都映射到一个处理器上了。

image-20240629163124909

多对多模型

多对多模型结合了一对一模型和多对一模型的优点,将多个用户线程映射到多个内核线程上。由线程库负责在可用的可调度实体上调度用户线程,这使得线程的上下文切换非常快,因为它避免了系统调用。但是增加了复杂性和优先级倒置的可能性,以及在用户态调度程序和内核调度程序之间没有广泛(且高昂)协调的次优调度。

多对多模型的优点有:

  1. 一个用户线程的阻塞不会导致所有线程的阻塞,因为此时还有别的内核线程被调度来执行;
  2. 多对多模型对用户线程的数量没有限制;
  3. 在多处理器的操作系统中,多对多模型的线程也能得到一定的性能提升,但提升的幅度不如一对一模型的高。

image-20240629163145410

线程的生命周期

当线程的数量小于处理器的数量时,线程的并发是真正的并发,不同的线程运行在不同的处理器上。但当线程的数量大于处理器的数量时,线程的并发会受到一些阻碍,此时并不是真正的并发,因为此时至少有一个处理器会运行多个线程。

在单个处理器运行多个线程时,并发是一种模拟出来的状态。操作系统采用时间片轮转的方式轮流执行每一个线程。现在,几乎所有的现代操作系统采用的都是时间片轮转的抢占式调度方式,如我们熟悉的Unix、Linux、Windows及macOS等流行的操作系统。

我们知道线程是程序执行的最小单位,也是任务执行的最小单位。在早期只有进程的操作系统中,进程有五种状态,创建、就绪、运行、阻塞(等待)、退出。早期的进程相当于现在的只有单个线程的进程,那么现在的多线程也有五种状态,现在的多线程的生命周期与早期进程的生命周期类似。

image-20240629163308718

线程与进程的区别

  • 本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
  • 在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小
  • 稳定性方面:进程中某个线程如果崩溃了,可能会导致整个进程都崩溃。而进程中的子进程崩溃,并不会影响其他进程。
  • 内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源
  • 包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程

协程

协程,英文Coroutines,是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序员自己写程序来管理的轻量级线程叫做『用户空间线程』,具有对内核来说不可见的特性。

image-20240629163537069

协程的目的

在传统的J2EE系统中都是基于每个请求占用一个线程去完成完整的业务逻辑(包括事务)。所以系统的吞吐能力取决于每个线程的操作耗时。如果遇到很耗时的I/O行为,则整个系统的吞吐立刻下降,因为这个时候线程一直处于阻塞状态,如果线程很多的时候,会存在很多线程处于空闲状态(等待该线程执行完才能执行),造成了资源应用不彻底。

最常见的例子就是JDBC(它是同步阻塞的),这也是为什么很多人都说数据库是瓶颈的原因。这里的耗时其实是让CPU一直在等待I/O返回,说白了线程根本没有利用CPU去做运算,而是处于空转状态。而另外过多的线程,也会带来更多的ContextSwitch开销。

对于上述问题,现阶段行业里的比较流行的解决方案之一就是单线程加上异步回调。其代表派是node.js以及Java里的新秀Vert.x。

而协程的目的就是当出现长时间的I/O操作时,通过让出目前的协程调度,执行下一个任务的方式,来消除ContextSwitch上的开销。

协程的特点

  1. 线程的切换由操作系统负责调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
  2. 线程的默认Stack大小是1M,而协程更轻量,接近1K。因此可以在相同的内存中开启更多的协程。
  3. 由于在同一个线程上,因此可以避免竞争关系而使用锁。
  4. 适用于被阻塞的,且需要大量并发的场景。但不适用于大量计算的多线程,遇到此种情况,更好实用线程去解决。

协程的原理

当出现IO阻塞的时候,由协程的调度器进行调度,通过将数据流立刻yield掉(主动让出),并且记录当前栈上的数据,阻塞完后立刻再通过线程恢复栈,并把阻塞的结果放到这个线程上去跑,这样看上去好像跟写同步代码没有任何差别,这整个流程可以称为coroutine,而跑在由coroutine负责调度的线程称为Fiber。比如Golang里的 go关键字其实就是负责开启一个Fiber,让func逻辑跑在上面。

由于协程的暂停完全由程序控制,发生在用户态上;而线程的阻塞状态是由操作系统内核来进行切换,发生在内核态上。
因此,协程的开销远远小于线程的开销,也就没有了ContextSwitch上的开销。

协程与线程比较

比较项 线程 协程
占用资源 初始单位为1MB,固定不可变 初始一般为 2KB,可随需要而增大
调度所属 由 OS 的内核完成 由用户完成
切换开销 涉及模式切换(从用户态切换到内核态)、16个寄存器、PC、SP…等寄存器的刷新等 只有三个寄存器的值修改 - PC / SP / DX.
性能问题 资源占用太高,频繁创建销毁会带来严重的性能问题 资源占用小,不会带来严重的性能问题
数据同步 需要用锁等机制确保数据的一直性和可见性 不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

线程:适合CPU密集型任务,利用多核并行计算能力,适合需要高并发的场景,但有较高的资源占用和上下文切换开销。

协程:适合I/O密集型任务,通过非阻塞I/O和协作调度提高效率,轻量级且避免复杂的同步问题,但无法利用多核并行计算。

部分学习自:[一文读懂什么是进程、线程、协程](https://www.cnblogs.com/Survivalist/p/11527949.html)