Java/Python/Go不同开发语言在进程、线程和协程的设计差异
- 1. 进程、线程和协程上的差异
- 1.1 进程、线程、协程的定义
- 1.2 进程、线程、协程的差异
- 1.3 进程、线程、协程的内存成本
- 1.4 进程、线程、协程的切换成本
- 2. 线程、协程之间的通信和协作方式
- 2.1 python如何实现线程通信?
- 2.2 java如何实现线程通信?
- 2.3 go如何实现线程通信?
- 3. 常用线程池的实现和使用方式
- 3.1 python常用线程池
- 3.2 java常用线程池
- 3.3 go常用线程池
- 4. 疑问和思考
- 4.1 go语言中,协程的成本已经很低,还有必要使用线程池吗?
- 5. 参考文档
在多线程项目开发时,最常用、最常遇到的问题是
1,线程、协程安全
2,线程、协程间的通信和控制
本文主要探讨不同开发语言go、java、python在进程、线程和协程上的设计和开发方式的异同。
1. 进程、线程和协程上的差异
1.1 进程、线程、协程的定义
-
进程
进程是操作系统进行资源分配的基本单位,每个进程都有自己的独立内存空间,不同的进程之间无法相互干扰。由于进程比较重,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。 -
线程
线程又叫做轻量级进程,是进程的一个实体,是处理器任务调度和执行的基本单位位(能够申请到cpu资源执行相关任务)。它是比进程更小的能独立运行的基本单位。线程只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
线程的执行需要申请对应的cpu资源,因此线程切换涉及CPU的资源切换(保存cpu上下文、触发软中断暂停当前线程、从就绪线程中选择一个执行),过程中会涉及用户态 -> 内核态(切换cpu)-> 用户态的切换,因此开销比较大。 -
协程
协程,又称微线程,是一种用户态的轻量级线程,协程的调度完全由用户控制(也就是在用户态执行)。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到线程的堆区,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,所以上下文的切换非常快(协程切换,线程不变,因此不需要切换cpu,不进行内核态切换,成本较低)。
进程、线程、协程之间的关系可以如下图诠释
1.2 进程、线程、协程的差异
线程进程的区别:
- 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位,cpu运行任务是运行线程
- 资源开销:每个进程都有独立的代码和数据空间,程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一进程的线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
- 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的。
- 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的。
- 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
- 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。两者均可并发执行。
协程与线程的区别:
- 一个线程可以有多个协程。
- 大多数业务场景下,线程进程可以看做是同步机制,而协程则是异步。
- 线程是抢占式,而协程是非抢占式的,所以需要用户代码释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
- 协程并不是取代线程,而且抽象于线程之上。线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行。
1.3 进程、线程、协程的内存成本
进程占用内存
- 32 位操作系统只支持 4G 内存的内存条,这是因为进程在 32 位操作系统中最多只能占用 4G 内存
- 在 64 位操作系统中可以占用更多内存。
线程占用内存
- 一般是 10MB,不同的操作系统版本之间有些差异,区间在 4M - 64M。
协程占用内
- 一个协程占用 2KB 左右的内存
内存占用: 进程 >> 线程 >> 协程
更低的内存占用代表着更低的资源切换成本和可以提供更高的并发。
1.4 进程、线程、协程的切换成本
不同的进程享有独立的资源,因此进程切换,需要执行如下2个步骤
- 切换页目录以使用新的地址空间(切换虚拟内存空间)
- 切换内核栈和硬件上下文(切换cpu资源)
相同进程的线程共享相同的内存,因此切换线程
- 使用的是进程的内存资源,不需要切换虚拟内存空间
- 切CPU换上下文时,需要耗费 CPU 时间,但是进程切换的开销相差不大(几微秒)。
相同线程的协程使用相同的内存和cpu资源,因此协程切换
- 在用户空间发生,不需要切换cpu,只需要切换简单CPU寄存器状态
- 一次协程的上下文切换最多需要几十纳秒的时间。
切换成本: 进程切换 > 线程切换 > 协程切换
2. 线程、协程之间的通信和协作方式
线程、协程之间的通信主要用于2个目的
- 控制线程、协程的执行顺序(触发条件、逻辑启停等)
- 线程、协程之间传递信息,用于在不同线程、协程之间实现业务逻辑
- 感知子线程、协程是否已经执行完成
注意,
如果不同的线程进行在写操作时,需要注意变量的线程安全问题
- 如果使用的的对象是线程安全的,不需要加锁保护,但是需要注意多个线程使用相同的对象以及相关对象的性能问题
- 如果使用的对象不是线程安全的,注意进行锁保护。
2.1 python如何实现线程通信?
通常使用如下方法进行线程同步,可以根据实际情况调整
- 共享变量
- queue队列
更多可以参考 python的多线程及线程间的通信方式
2.2 java如何实现线程通信?
通常使用如下方法进行线程同步,可以根据实际情况调整
- 锁与同步
- 等待/通知机制
- 信号量
- 管道
更多可以参考 Java线程间的通信
2.3 go如何实现线程通信?
在go中,常用的是协程(goroutine)进行多并发,因此探讨的通信方式都是以协程(goroutine)进行讨论。
实现多个goroutine间的同步与通信大致有:
- 全局共享变量
- channel通信(CSP模型)
- Context包
这3种方法具体实现可以参考文档 深入golang之—goroutine并发控制与通信
3. 常用线程池的实现和使用方式
3.1 python常用线程池
线程池的基类是 concurrent.futures 模块中的 Executor,Executor 提供了两个子类,即 ThreadPoolExecutor 和 ProcessPoolExecutor,其中 ThreadPoolExecutor 用于创建线程池,而 ProcessPoolExecutor 用于创建进程池。
由于全局GIL锁存在,python多线程本质上同一时间只能1个线程在执行,并不能高效的利用所有的CPU核心。
1, 如果使用多线程,线程的类型基本都是IO密集型,线程进入IO等到时会自动释放GIL索引,因此GIL锁的存在对于这种类型的计算性能影响不算大
2,如果使用多线程,线程的类型基本都是CPU密集型,只能等待解释器不间断运行了1000字节码(Py2)或运行15毫秒(Py3)后,该线程也会放弃GIL,切换到其他的线程执行。
使用线程池来执行线程任务的步骤如下:
- 调用 ThreadPoolExecutor 类的构造器创建一个线程池。
- 定义一个普通函数作为线程任务。
- 调用 ThreadPoolExecutor 对象的 submit() 方法来提交线程任务。
- 当不想提交任何任务时,调用 ThreadPoolExecutor 对象的 shutdown() 方法来关闭线程池。
def test(value1, value2=None):
print("%s threading is printed %s, %s"%(threading.current_thread().name, value1, value2))
time.sleep(2)
return 'finished'
def test_result(future):
print(future.result())
if __name__ == "__main__":
import numpy as np
from concurrent.futures import ThreadPoolExecutor
threadPool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="test_")
for i in range(0,10):
future = threadPool.submit(test, i,i+1)
threadPool.shutdown(wait=True)
更多使用参考PYTHON线程池及其原理和使用(超级详细)
3.2 java常用线程池
常用4中类型的线程池
- newFixedThreadPool
构造函数
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
从构造方法可以看出,它创建了一个固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大值nThreads。线程池的大小一旦达到最大值后,再有新的任务提交时则放入无界阻塞队列中,等到有线程空闲时,再从队列中取出任务继续执行。
- newCachedThreadPool
构造函数
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
从构造方法可以看出,它创建了一个可缓存的线程池。当有新的任务提交时,有空闲线程则直接处理任务,没有空闲线程则创建新的线程处理任务,队列中不储存任务。线程池不对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。如果线程空闲时间超过了60秒就会被回收。(使用方法不是非常推荐)
- newSingleThreadExecutor
构造函数
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
从构造方法可以看出,它创建了一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行,无法指定最大线程池数量。(使用方法不是非常推荐)
- newScheduledThreadPool
构造函数
public class OneMoreStudy {
public static void main(String[] args) {
final SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
System.out.println("提交时间: " + sdf.format(new Date()));
scheduledThreadPool.schedule(new Runnable() {
@Override
public void run() {
System.out.println("运行时间: " + sdf.format(new Date()));
}
}, 3, TimeUnit.SECONDS);
scheduledThreadPool.shutdown();
}
}
这个方法创建了一个固定大小的线程池,支持定时及周期性任务执行。创建并执行ScheduledFuture,该ScheduledFuture在指定的延迟后启用,任务立即提交给线程池,线程池安排线程在指定时间后正式开始运作,运作以后保持正常节奏(类似调度任务)
根据使用习惯选择合适的方法类,更多可以参考Java中常用的四种线程池
3.3 go常用线程池
go的基础方法类中没有实现线程池,需要自己实现,或者引入第三方库进行实现。
4. 疑问和思考
4.1 go语言中,协程的成本已经很低,还有必要使用线程池吗?
梳理常用的开发语言中,是有已经有了现成的线程池方法(类)提供使用,情况如下:
开发语言 | 是否支持线程池 | 备注 |
---|---|---|
python | 是 | |
java | 是 | |
go | 否 | 可以引用第三方的库或者自己实现 |
go的协程已经把单个协程的成本降低到足够低,还有必要设计线程池吗?该问题在Go Forum 中 skillian 做了解答。
我引用回复
Like lutzhorn said: Need? No.
But for some workloads in some projects, it might make sense to have a general worker pool implementation. The benefit is that the memory consumption can be limited by not allowing the number of goroutines to exceed whatever the pool allows, though I’m unsure of what order of magnitude of goroutines you need before that benefit is manifested.
Francesc Campoy created a fractal with 4 million goroutines (link 55) and it worked and scaled, but not perfectly. The issue wasn’t with the number of goroutines but that the runtime spent more time managing the goroutines than the goroutines actually worked. By giving the goroutines more work, (I think instead of each goroutine processing only one pixel, they processed the whole line?) the solution still scaled and ended up performing better.
翻译过来就是
1, 通常不需要
2, 除了特殊场景,特殊项目上,线程池是有意义的。这样做的好处是,可以通过不允许超过池允许的程序的数量来限制内存消耗,尽管我不确定在显示出这种好处之前需要多少量级的程序。
5. 参考文档
- 一文快速了解进程、线程与协程
- 进程、线程以及协程的区别
- 深入golang之—goroutine并发控制与通信
- Java线程间的通信