概念介绍
进程process
- 进程是操作系统内独立且隔离的执行单元。它包含自己的内存空间、代码、数据和系统资源。进程由操作系统内核进行管理和调度。
- 每个进程都有独特的进程标识符(PID),用于区分其他进程。
- 进程提供很高的隔离性,一个进程不能直接访问另一个进程的内存或资源。要在进程之间进行通信,需要使用进程间通信(IPC)机制,如管道、套接字或消息传递。
- 启动一个进程通常涉及复制父进程的状态和内存,之后子进程独立运行。
线程thread
- 线程是进程内的最小执行单元。与进程不同,线程共享其父进程的内存空间和资源。
- 线程比进程轻量,创建和在线程之间切换的开销较小。由于它们共享相同的内存空间,同一进程内的线程之间的通信和数据共享更为高效。
- 进程内的线程可以访问共享数据,这可能导致同步问题,并需要线程同步机制(如锁和信号量)来确保数据的完整性。
- 线程适用于涉及并行性的任务,例如在网络服务器中处理多个连接。
纤程fiber
纤程 (fiber)和协程 (coroutine) 都是用于实现并发编程的技术, 但有一些区别:
-
纤程 (fiber)是由操作系统内核支持和管理的轻量级线程。纤程拥有自己的调用栈和局部变量, 可以与其他纤程共享内存空间。操作系统可以在多个纤程之间快速切换, 支持大量的并发执行。
-
协程 (coroutine)是一种程序语言实现的轻量级线程, 协程切换不需要操作系统支持, 完全由程序语言自己控制。协程允许程序保留执行状态, 对于一些需要维护大量并发连接的应用很有用。
-
纤程由操作系统调度, 协程由程序自行调度, 纤程切换需要 Mode switch, 协程切换只需要保存状态。
-
Go 和 C++都支持协程, 通过语言级的 goroutine 和 coroutine 来实现。但它们底层是否由操作系统的纤程来支持依赖具体的运行环境。
-
总体来说, 纤程和协程解决的问题类似, 都是为了发挥多核 CPU 的并行计算能力。但实现机制不同, 纤程由操作系统提供, 协程由语言提供。在实际使用中, 根据需求选择合适的并发编程抽象。
纤程 (fiber)和线程 (thread) 的主要区别在于:
-
线程是操作系统调度的基本单位, 纤程是轻量级的线程, 在线程内部调度。
-
线程拥有自己独立的执行栈和系统资源, 纤程只有独立的执行栈, 共享线程的其他资源。
-
在多线程中, 线程的切换需要操作系统切换 CPU 执行上下文, 这是比较耗时的。纤程切换只涉及执行栈的切换, 效率很高。
-
一个线程可以包含多个纤程, 这些纤程共享线程的资源。一个线程的崩溃会导致进程的崩溃, 而一个纤程的崩溃只会影响当前线程中的其他纤程。
-
线程数一般根据 CPU 核数确定, 过多的线程会导致频繁的上下文切换。而纤程数可以设定非常大。
所以纤程是在线程内部提供的一种更轻量级的执行单元。线程和进程适合资源隔离和保护, 而纤程适合大规模并发和高效调度。把复杂任务分解到不同的纤程, 并在少量线程上调度纤程, 可以高效利用多核 CPU。
协程coroutine
- 协程是一种编程抽象,允许函数在特定点暂停和恢复执行,而不会丢失其状态。也称为“协作式多任务处理”。
- 与线程不同,线程是由操作系统抢占式调度的,协程依赖于开发者在适当的时候主动放弃控制权,将执行权交给另一个协程。
- 协程通常比线程更轻量级,因为它们不需要为每个协程分配独立的内存栈,而是与调用它们的函数共享相同的栈。
- 协程适用于管理涉及顺序或非阻塞操作的任务,例如异步 I/O 或事件驱动编程。
联系和区别
- 进程和线程由操作系统进行管理,而协程由应用程序本身或协程库进行管理。
- 进程是彼此隔离的,而线程共享同一进程的内存空间和资源。
- 线程比进程更轻量级,创建和在线程之间切换的开销较小。
- 协程比线程更轻量级,因为不需要为每个协程分配独立的内存栈。
- 进程和线程是并发的,由操作系统调度它们的执行。协程是协作式的,需要主动放弃控制权。
- 进程间通信需要使用 IPC 机制,而同一进程内的线程可以直接通过共享内存进行通信。
- 线程适用于并行任务,而协程适用于顺序或非阻塞操作。
进程
进程管理的资源
那么进程都管理哪些资源呢? 通常包括
- 内存资源、
- IO 资源、
- 信号处理等部分。
进程运行起来必然会涉及到对内存资源的管理。内存资源有限,
- 操作系统采用虚拟内存技术,把进程虚拟地址空间划分成用户空间和内核空间。
地址空间
4 GB 的进程虚拟地址空间被分成两部分:用户空间和内核空间
用户空间
用户空间按照访问属性一致的地址空间存放在一起的原则,划分成 5 个不同的内存区域。 访问属性指的是 “可读、可写、可执行等 。
-
代码段:
- 代码段是用来存放可执行文件的操作指令,可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以==只准许读取操作,它是不可写的==。
-
数据段:
- 数据段用来存放可执行文件中已初始化全局变量,换句话说就是==存放程序静态分配的变量和全局变量==。
-
BSS 段:
- BSS(Block Started by Symbol) 段包含了程序中==未初始化的全局变量==,在内存中 bss 段全部置零。
-
堆 heap:
- 堆是用于==存放进程运行中被动态分配的内存段==,它的大小并不固定,可动态扩张或缩减。
- 当进程调用 malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);
- 当利用 free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
-
栈 stack:
- 栈是用户存放程序临时创建的局部变量,也就是函数中定义的变量(但不包括 static 声明的变量,static 意味着在数据段中存放变量)。
- 除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。
- 由于栈的先进后出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,可以把堆栈看成一个寄存、交换临时数据的内存区。
上述几种内存区域中数据段、BSS 段、堆通常是被连续存储在内存中,在位置上是连续的,而代码段和栈往往会被独立存放。堆和栈两个区域在 i386 体系结构中栈向下扩展、堆向上扩展,相对而生。
- 在 Linux 下用 size 命令可以查看编译后程序的各个内存区域大小:
$ size /usr/local/sbin/sshd
text data bss dec hex filename
1924532 12412 426896 2363840 2411c0 /usr/local/sbin/sshd
内核空间
- 在 x86 32 位系统里,Linux 内核地址空间是指虚拟地址从 0xC0000000 开始到 0xFFFFFFFF 为止的高段内存地址空间,总计 1 G 的容量,
- 包括了内核镜像、物理页面表、驱动程序等运行在内核空间 。
线程
线程资源和开销
同一进程中的多条线程共享该进程中的全部系统资源,如虚拟地址空间,文件描述符文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈、寄存器环境、线程本地存储等信息。
- 线程创建的开销主要是线程堆栈的建立,分配内存的开销。
- 这些开销并不大,最大的开销发生在线程上下文切换的时候。
线程分类
线程按照实现位置和方式的不同,也分为用户级线程和内核线程,下面一起来看下这两类线程的差异和特点。
用户级线程
实现在用户空间的线程称为用户级线程。
-
用户线程是完全建立在用户空间的线程库之上,
-
用户线程的创建、调度、同步和销毁全由用户空间的库函数完成,不需要内核的参与,
-
因此这种线程的系统资源消耗非常低,且非常的高效。
-
特点:
- 用户线级线程==只能参与竞争该进程的处理器资源==,不能参与全局处理器资源的竞争。
- 用户级==线程切换都在用户空间进行==,开销极低。
- 用户级线程调度器在用户空间的线程库实现,内核的调度对象是进程本身,==内核并不知道用户线程的存在==。
- 缺点:
- 如果触发了引起阻塞的系统调用的调用,会立即阻塞该线程所属的整个进程。
- 系统只看到进程看不到用户线程,所以只有一个处理器内核会被分配给该进程 ,也就不能发挥多核 CPU 的优势。
内核级线程
内核级线程是指,内核线程建立和销毁都是由操作系统负责、通过系统调用完成的,内核维护进程及线程的上下文信息以及线程切换。
- 特点:
- 内核级线级==能参与全局的多核处理器资源分配==,充分利用多核 CPU 优势。
- ==每个内核线程都可被内核调度==,因为线程的创建、撤销和切换都是对内核管理的。
- 一个内核线程阻塞与他同属一个进程的线程仍然能继续运行。
- 缺点:
- 内核级线程调度开销较大。==调度内核线程的代价可能和调度进程差不多昂贵==,代价要比用户级线程大很多。
- ==线程表是存放在操作系统固定的表格空间或者堆栈空间里==,所以内核级线程的数量是有限的。
Linux 线程实现
Linux 并没有为线程准备特定的数据结构,因为 Linux 只有 task_struct 这一种描述进程的结构体。在内核看来只有进程而没有线程,线程调度时也是当做进程来调度的。
- Linux 所谓的线程其实是与其他进程共享资源的轻量级进程。
为什么说是轻量级呢?在于它只有一个最小的执行上下文和调度程序所需的统计信息,它只带有进程执行相关的信息,与父进程共享进程地址空间 。
轻量级进程
- 轻量级线程 Light-weight Process 简称 LWP ,是一种由内核支持的用户线程,每一个轻量级进程都与一个特定的内核线程关联。
- 它是基于内核线程的高级抽象,系统只有先支持内核线程才能有 LWP。每一个进程有一个或多个 LWPs ,每个 LWP 由一个内核线程支持,在这种实现的操作系统中 LWP 就是用户线程。
轻量级进程最早在 Linux 内核 2.0.x 版本就已实现,应用程序通过一个统一的 clone() 系统调用接口,用不同的参数指定创建的进程是轻量进程还是普通进程。
- 特点和缺点:
- 由于轻量轻量级进程基于内核线程实现,因此它的特点和缺点就是内核线程的特点和缺点。
查看 LWP 信息
轻量级线程也没什么神秘的,用 Linux 的 pstack 命令可以查看进程的轻量级线程 LWP 信息。下图的黄色字体就是打印出的轻量级线程 ID ,以及该线程的调用堆栈信息,从最新的栈帧开始往下排列。
用法示例: pstack pid
协程
其实协程的概念出来的比线程还早,只不过最近才被人们更多的提起。
协程之所以最近被大家熟知,是由于 Python 和 Go 从语言层面提供了对协程更好的支持,尤其是以 Goroutine 为代表的 Go 协程实现,很大程度上降低了协程使用门槛。
why 协程?
当今无数的 Web 服务和互联网服务,本质上大部分都是 IO 密集型服务,什么是 IO 密集型服务?意思是处理的任务大多是和网络连接或读写相关的高耗时任务。
-
IO 密集型服务的瓶颈不在 CPU 处理速度,而在于尽可能快速的完成高并发、多连接下的数据读写。
-
以前的多线程、多进程解决方案效果都不好:
- 如果用多线程,高并发场景的大量 IO 等待会导致多线程被频繁挂起和切换,非常消耗系统资源,同时多线程访问共享资源存在竞争问题。
- 如果用多进程,不仅存在频繁调度切换问题,同时还会存在每个进程资源不共享的问题,需要额外引入进程间通信机制来解决。
-
协程出现给高并发和 IO 密集型服务开发提供了另一种选择。
什么是协程
- 协程 Coroutines 是一种比线程更加轻量级的微线程。
- 类比一个进程可以拥有多个线程,一个线程也可以拥有多个协程,因此协程又称微线程和纤程。
可以粗略的把协程理解成子程序调用,每个子程序都可以在一个单独的协程内执行。
调度开销
内核线程(不采用纯用户线程的原因在于性能更差,还不能利用多处理器的性能)是被内核所调度,线程被调度切换到另一个线程上下文的时候,需要保存一个用户线程的状态到内存,恢复另一个线程状态到寄存器,然后更新调度器的数据结构,这几步操作设计用户态到内核态转换,开销比较多。
- 协程的调度完全由用户控制,协程拥有自己的寄存器上下文和栈,
- 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作用户空间栈,完全没有内核切换的开销。
动态协程栈
协程拥有自己的寄存器上下文和栈,协程调度切换时将寄存器上下文和栈保存下来,在切回来的时候,恢复先前保存的寄存器的上下文和栈。
- Goroutine 是 Golang 的协程实现。Goroutine 的栈只有 2 KB 大小,而且是动态伸缩的,可以按需调整大小,最大可达 1 G,相比线程来说既不浪费又灵活了很多。
线程也都有一个固定大小的内存块来做栈,一般会是 2 MB 大小,线程栈会用来存储线程上下文信息。2 MB 的线程栈和协程栈相比大了很多。
协程实现
Python 协程实现
正如刚才所写的代码示例,python 2.5 中引入 yield/send 表达式用于实现协程,但这种通过生成器的方式使用协程不够优雅。
- python 3.5 之后引入 async/await ,简化了协程的使用并且更加便于理解。
Go 语言协程实现
Golang 在语言层面实现了对协程的支持,Goroutine 是协程在 Go 语言中的实现, 在 Go 语言中每一个并发的执行单元叫作一个 Goroutine ,Go 程序可以轻松创建成百上千个协程并发执行。
Go 协程调度器有三个重要数据结构:
- G 表示 Goroutine ,它是一个待执行的任务;
- M 表示操作系统的线程,它由操作系统的调度器调度和管理;
- P 表示处理器 Processor,它可以被看做运行在线程上的本地调度器;
- Go 调度器最多可以创建 10000 个线程,可以通过设置 GOMAXPROCS 变量指能够正常运行的运行,这个变量的默认值等于 CPU 个数,也就是线程数等于 CPU 核数,
- 这样不会触发操作系统的线程调度和上下文切换,所有的调度由 Go 语言调度器触发,都是在用户态,减少了非常多的调用开销。