在实际工作开发中我们需要根据工作负载是CPU密集型还是I/O密集型,使用不同的方式来解决问题。下面我们先来看这些概念,然后再讨论其影响。
在程序执行时,工作负载的执行时间会受以下因素限制:
- CPU的速度--例如,运行归并排序算法。工作负载被称为CPU密集型。
- I/O速度--例如,进行REST调用或数据库查询。工作负载被称为I/O密集型。
- 可用内存量--工作负载被称为内存密集型。
让我们举一个具体的例子。假设将在一个四核的机器上运行我们的程序,因此Go将实例化四个操作系统线程,在那里goroutine将被执行。起初的情况可能并不理想:我们可能面临有四个核和四个goroutine,但是只执行一个goroutine的场景,如下图所示:
最终,通过我们已经介绍过的“工作窃取”概念,P1可能会从本地P0队列中窃取goroutine。在下图中,P1从P0窃取了三个goroutine(实际上,Go窃取算法不会把另一个P的本地队列中的所有的G都窃取走,只会窃取一半)。在这种情况下,Go调度器也可能最终将所有的goroutine分配给不同的操作系统线程,但不能保证何时会发生这种情况,然而,由于调度器的主要目标之一就是优化资源(这里指goroutine的分布),考虑到工作负载的性质,我们应该最终处于这样的场景中。
这种情况仍然不是最优秀的,因为最多有两个goroutine在动行。假设这台机器只运行我们的程序(操作系统进程除外),所以P2和P3是空闲的。最终,操作系统应该移动M2和M3,如下图所示,
在这里,操作系统调度器决定将M2移动到P2并将M3移动到P3。但同样不能保证这种情况何时发生,但是假设一台机器只执行我们的四线程应用程序,上图所示的应该是最终画面。
情况发生了变化,它已成为最佳状态。这四个goroutine在不同的线程中运行,并且线程在不同的核上,这种方法(在一定程度上)减少了goroutine和线程级别的上下文的切换量。
我们(Go开发者)无法设计和要求这个全局图,然而正如我们所见,在CPU密集型工作负载的情况下,我们可以在有利的条件下启用它:基于GOMAXPROCS创建一个worker池。
在实现worker池模式时,我们已经看到,池中最佳的goroutine数量取决于工作负载的类型。如果worker执行的工作负载是I/O密集型的,那么值主要取决于外部系统。如果工作负载受CPU限制,则goroutine的最佳数量接近可用线程的数量。在设计并发应用程序时,了解工作负载的类型(I/O密集型还是CPU密集型)至关重要。