浅谈多线程
需了解
参考:
Piotr Kołaczkowski:How Much Memory Do You Need to Run 1 Million Concurrent Tasks?
多核心
一般情况下,比如嵌入式芯片多核心,一个核心只有一个线程。核心数目一定,操作系统线程数目一定。
线程
超线程:超线程技术(Hyper-Threading Technology)是由英特尔推出的一项技术,它可以在单个物理处理器核心上模拟多个逻辑处理器核心,以提高处理器的并行度和整体性能。一般单核会被模拟出一个线程(共2线程)。
单线程
- 简单的理解只有一个工人
- 相当于单核CPU(未使用超线程技术)
- 人脑就是单线程
多线程
- 简单的理解同时有多个工人
- 多核心CPU
- 使用超线程技术
进程vs线程
对比点 | 线程(Thread) | 进程(Process) |
---|---|---|
资源共享 | 共享进程资源,如内存、变量 | 独立资源,不共享内存 |
开销 | 创建、切换成本低 | 创建、切换成本高 |
通信方式 | 直接访问共享变量 | 需要 IPC(如管道、消息队列、共享内存等) |
适用于 | I/O 密集型任务(如文件读写、网络请求) | CPU 密集型任务(如数据计算、AI 训练) |
线程之间的资源共享
- 内存空间(代码段、数据段、堆)
- 全局变量
- 文件描述符
- 网络连接
- 环境变量
共享资源竞争处理机制:
锁(Lock/Mutex) 来防止多个线程同时访问同一个变量,避免数据竞争问题。
信号量(Semaphore) 来控制多个线程对共享资源的访问数量。
条件变量(Condition Variable) 来实现线程之间的同步。
进程之间的资源不共享
内存空间(不能直接访问其他进程的内存)
全局变量
文件描述符
环境变量
地址空间
代码和数据
进程间通信处理机制:
管道(Pipe)
消息队列(Message Queue)
共享内存(Shared Memory)
套接字(Socket)
文件(File)
操作系统
操作系统将多线程中的任务分配到多个核心,在操作系统的复杂的资源分配逻辑下交替执行。
一个工地中有多个挖掘机,每个挖掘机对应一个工人,相当于单核心单线程。每个工人的任务清单不同,每个工人都会按照工头(操作系统)的指挥交替轮换挖掘机来执行不同的任务,保证执行任务之间:任务同步、不出现资源竞争、访问数量,等。
同步异步任务
无论同步任务还是异步任务都是相对于单线程而言的
同步任务
必须按照顺序的一直执行下去,不能执行其他操作
你在做一件非常复杂的数学题,此时你只能专注这件事,并一步步地解决,不能被打扰
异步任务
在做一件事情的同时,另一件以上的事情同时在进行
一个餐厅里只有一个人(厨师兼服务员),同时要服务多个顾客,点餐的过程手动发给他们每人一个小本本(记录菜单),假设这个人有超能力可以快速移动、记录事件、窃听状态等,就可以一边做饭的同时也在等待所有顾客的点餐、就餐等耗时任务。
此时这个超能力就可以被理解为事件循环(Event Loop)
事件循环
常见于许多编程语言:JavaScript Python Java Rust Go Ruby Swift
等待阶段(Waiting Phase):
事件循环开始时,会处于等待状态,等待着异步任务的触发。
获取事件(Event Acquisition):
一旦有事件发生,事件循环会获取该事件并进行处理。这些事件可以是用户输入、网络请求、定时器到期等等。
事件处理(Event Handling):
事件循环会调用相应的回调函数或者处理器来处理获取到的事件。这些处理器可能包括用户定义的回调函数、系统提供的事件处理函数等。
执行任务(Task Execution):
在处理完事件后,事件循环可能会执行与事件相关的任务。这些任务可以是异步的,可能涉及IO操作、计算密集型任务等。
更新UI(Update UI)(在GUI编程中常见):
如果事件循环用于GUI编程,它可能会在处理完事件后更新用户界面,以反映最新的状态或者用户交互。
返回等待阶段(Return to Waiting Phase):
处理完事件后,事件循环会返回到等待状态,等待下一个事件的发生。
可异步任务
- HTTP请求响应
- I/O 操作
- 定时等待操作
语言
Python
我认为Python语言并不是真正意义上的多线程,因为Python标准库中的threading模块可占据操作系统的多个线程(一个主线程+多个副线程),但由于 Python 全局解释器锁(GIL)的存在,在多核 CPU 上它的多线程在任何时刻只有一个线程能够执行 Python 字节码。这种行为导致 Python 线程并不能真正做到 CPU 并行,而是 伪并行(concurrent, not parallel)。比如每个子线程的
join()
就是权给主线程。这就意味着 Python 多线程在 CPU 密集型任务上并不能实现性能的提升,但对于 I/O 密集型任务(如网络请求、文件操作等),多线程依然是有效的。当然可以使用 multiprocessing 模块,该模块支持在多个进程之间并发执行任务,每个进程都有自己独立的内存空间,从而避免了全局解释器锁(GIL)的限制,并能够充分利用多核 CPU。
参考:
YouTube-码农高天:【python】听说Python的多线程是假的?它真的没有存在的价值么?
IO密集型任务
1 | words = list(range(6)) |
以上代码其实更适合使用协程来完成,并且比操作系统级别的线程切换更轻便,没有竞争冒险问题(例:读者-写者问题、哲学家就餐问题、等),可见协程在网络传输的应用里更合适(例:爬虫)。
异步
- async和await是用于异步编程的关键字,引入了异步/协程(coroutine)的概念。
- 当函数被async关键字修饰时,该函数会返回一个协程对象(coroutine object)。
- 当await关键字在函数中使用时,它会暂停(放权)当前协程的执行,直到等待的异步操作完成,此时协程中其他任务可以被继续执行。
- Python的异步操作需要一个事件循环(event loop),例如asyncio库提供的循环,来管理和调度执行。
Python多线程用途
那么Python的多线程就没有意义了吗?并非如此,可以将协程和Python的多线程结合起来处理 大量计算密集任务 同时处理 低延迟的小任务
但是,如果单纯的使用协程处理以上的问题,就会出现执行大量计算密集型的时候导致无法放权给小的低延迟任务,这时就需要操作系统级别的线程来处理让权问题
例如以下代码中的fib()
是一个计算密集型任务,此任务会造成计算过程无法放权导致其他低asyncio.sleep(0)
延迟任务无法被执行,这会导致在处理大量密集型任务的时候没有同时处理sleep()
操作,从而导致总处理时间相比同时执行两种任务的时间更长。
1 | async def long_task(): |
如果加入多线程,将计算密集型任务扔给一个操作系统级别的线程,此时的情况就会得到改善,虽然依旧同一时刻只能处理一个线程,但操作系统级别的多线程会放权给另一个处理权给小的低延迟任务线程,从而实现执行大量密集型任务的同时也在处理小的低延迟任务(IO、请求响应等…)
1 | def long_task(): |
Go
Go语言中的多线程是基于goroutines和channel的。Goroutines是Go语言中的轻量级线程,它们是由Go运行时(runtime)进行调度和管理的。使用go关键字可以轻松地创建一个新的goroutine,例如go f(x, y, z)会在一个新的goroutine中执行函数f。
Goroutines在设计上是为了使并发编程更加简单和高效。它们在用户空间内进行调度,这意味着它们的创建和管理不需要操作系统内核的直接干预。创建和切换的开销远低于传统的操作系统级线程。
Channel是Go语言中用于goroutines之间的通信的同步原语。它们提供了一种方式,让goroutines之间可以安全地交换信息,无需担心并发访问的问题。
Go并发模型
Golang 的并发模型基于 CSP(Communicating Sequential Processes)理论,通过通道(channels)来实现 goroutine 之间的通信。它的运行时环境会维护一个由一组操作系统线程组成的线程池,称为M
调度器(M:操作系统线程,N:goroutine)。在这个调度器中,多个 goroutine 可以被调度到少量的操作系统线程上并行执行,不直接依赖于操作系统的线程管理,而是由 Go 运行时环境在操作系统线程上进行调度和管理。
Go与操作系统线程管理
当一个新的 goroutine 被创建时,Golang 运行时会将其放入调度队列中,并决定将其调度到哪一个空闲的操作系统线程上执行。这种调度方式可以根据系统负载和资源利用情况动态地进行调整,从而实现高效的并发执行。
总的来说,Go语言的多线程模型是一种用户级线程模型,但是它通过运行时的智能调度,能够有效地利用多核处理器的并行性能。这种模型在处理并发任务时既高效又易于管理。
真正的多线程
golang 多线程一般主线程无需等待子线程,可以理解成主线程和子线程是同时执行的
这个函数就说明了golang主线程启动一个子线程,当gen中这个子线程开始执行的同时主线程可以打印“执行return!”,无需调用像Python中main函数调用子线程join()
这样的函数等待子线程完成。
1 |
|
JavaScript
在浏览器中,JavaScript 是单线程执行的。这意味着在任何给定时间点,JavaScript 代码只能由一个线程执行。这个单线程通常称为主线程或 UI 线程(比如浏览器的某个标签页的JavaScript执行)。
NodeJS中JavaScript 也可以实现多线程编程,例如Worker Threads API 允许开发者在 Node.js 中创建独立的线程,这些线程可以执行 CPU 密集型任务、并行处理数据或执行其他需要并发执行的操作。这些线程是由操作系统调度和管理的
但是,需要注意的是,Node.js 是单线程的事件驱动模型,主线程上的事件循环仍然是单线程的,因此在任何给定时刻只有一个事件在主线程上执行。而 Worker 线程的执行是在独立的 JavaScript 执行环境中进行的,它们可以并行执行代码,它们之间通过线程间的消息传递机制进行通信,但不会影响主线程的事件循环。
启动每个 Worker 线程都会消耗一定的系统资源,包括内存和 CPU 资源。每个 Worker 线程都有自己的 JavaScript 执行环境和相关的资源,因此在启动大量的 Worker 线程时需要考虑系统资源的限制。因为Worker Threads 在 Node.js 中确实提供了真正的多线程支持,但不像 Go 那样轻量级。而 Node.js 的 Worker Threads 仍然是基于原生 OS 线程,所以它的 开销比 Goroutine 更大。
异步
async和await
- async和await是处理异步操作的关键字,它们基于Promises。
- 一个用async关键字声明的函数会返回一个Promise对象。
- 当await被用于一个异步操作时,它会暂停(放权)该函数的执行,直到Promise解决(resolved)。
- JavaScript的事件循环(event loop)是其运行时环境的一部分,所有异步行为都是通过这个事件循环来管理的。
Java
Java 中,多线程的实现是依赖于操作系统线程的。Java 的线程是由 Java 虚拟机(JVM)在操作系统上创建和管理的。在大多数情况下,Java 中的多个线程会映射到多个操作系统线程上。
Java 虚拟机会根据底层操作系统的线程调度机制来调度 Java 线程。这意味着在同一时刻,Java 中的多个线程可能会被操作系统调度为在不同的核心上并行执行,因此它们可以同时运行。
然而,需要注意的是,并不是每个 Java 线程都会直接映射到一个操作系统线程上,具体的映射方式取决于 JVM 的实现。有些 JVM 可能会使用一种称为“用户级线程”的技术,将多个 Java 线程映射到较少的操作系统线程上,从而提高线程的创建和销毁效率,但在某些情况下可能会降低并行度。