Go语言基础(4)——并发编程基础概念

103 阅读5分钟

Go语言基础之并发编程

并发与并行

并发的英文是concurrency,而并行是parallellism。并发指的是在操作系统的调试下,多个任务(线程)轮换地获得CPU资源,从而达到像是多个任务在同时执行的效果,因此并发是软件层面的实现,即使只有一个cpu,也能实现并发。而并行则是硬件层面的概念,多个并行的任务是真真切切在同时执行,这一般需要多核心CPU在硬件层面的支持。咱们现代的CPU,动不动就4核8核,因此现在基本上都能进行并行任务。

但并发和并行其实并不冲突,由于1个核就可以跑并发,那多个核当然也能,比如4个核跑着并发,而每个核上又跑着n个并发,这样一共就会有4n个并发任务了,当然,现在CPU的每个核心并不是完全等价,一般都会有小核和大核,甚至还会有一些跑特定功能的核,而且听说有些CPU和GPU厂商早已开始研发GCPU,也就是将CPU和GPU进行融合。

并发编程基础

一些概念

我们应用层的Coder一般都是面向并发编程的,并行一般都是操作系统/硬件驱动或者直接就是硬件实现的,并行大多数时候对我们都是透明的。因此我们在并发编程时,经常考虑的都是多任务在同一个CPU上的执行流程,这就产生了线程的生命周期管理,线程的通信、休眠与唤醒以及线程的销毁。刚才也说到了,现代的CPU和操作系统,基本上都是多核和支持并行的,因此有时候我们虽然在应用层,但还是可能会遇到并行任务的问题,最典型的就是原子性问题。

操作系统有进程和线程的概念,进程是资源管理的单位,比如内存、硬件设备等,而线程是CPU调度的单位。多个进程之间不能相互访问内存,只能通过第三方介质通信,比如网络、文件、通道之类的。一个进程至少包含1个线程,一般来说进程的入口main函数所在的线程叫作main线程,同一个进程中的线程却可以相互访问内存,最简单的访问内存就是在一个线程中访问另一个线程的变量,这种访问的原理其实是同一个进程中的所有线程的内存都是在堆上的,既然是同一个堆,那么当然就可以访问啦。

操作系统的线程

由于线程是操作系统的概念,因此像Java实现的线程,就真真切切的是操作系统的线程,每个Java线程都对应一个操作系统的线程,因此Java中的线程其实是非常重量级的,线程的创建、休眠、唤醒以及销毁都需要操作系统执行。而我们知识,现在操作系统为了系统的安全性与稳定性,都实现了内核态和用户态,因此线程的这些操作还涉及到内核态和应用态之间的切换。同时,内核态和应用态也跟进程一样,实际上是不支持内存的相互访问的,因此这些操作还涉及到内存copy。而在服务端开发中,一台机器可能会有上万个连接,要是开一万个线程,这些操作慢慢加起来,可以CPU就会浪费大量的时间在这些切换和调试上了,CPU的工作效率就会大大降低。

计算机世界有两个至理:抽象与复用,线程就可以理解成进程的抽象,并发也可以理解成并行的抽象。而复用就应用得更多了,我们的每行代码基本上都是奔着复用去的,不会有谁写的代码只会运行一次吧。。。对于操作系统级别的资源,我们在优化时一般也是遵循着两个思路,更快和更少。更快一般来说是减少通信来达到,比如内核态与应用态通过共享内存来减少内存copy,更少一般是通过复用来达到的。

既然线程的创建和销毁需要巨大的成本,那么我们就想办法减少线程的创建和销毁次数,这就需要将线程进行复用。对于操作系统来说,线程可以更简化地理解成一个tid-> cptr的map,tid是线程的唯一标识,而cptr是当前线程执行到的代码位置。这两个量其实都不涉及到线程真实执行的任务内容(代码算内容吗?也算吧,但代码也可以复用呀,因为代码编译在运行时所在的内存段几乎是不变的),因此我们其实是可以复用系统级别的线程的。我们将应用级线程所需要的数据和状态保存在应用的内存中,然后在真正的线程中去执行任务,任务执行完毕后,再去执行其他任务,这样系统级别的线程就可以一直存在且被复用,这种技术叫作线程池。

我们把池内线程数叫P,所需线程数叫N。在Java中,一般有四种线程池供使用,分别是:

  1. newCachedThreadPool创建一个可缓存的线程池,如果P>N,则这种线程池会将P-N部分的线程暂时缓存起来,过一段时间再回收,如果P<N,则会创建新的线程并加入池中
  2. newFixedThreadPool创建一个固定大小的线程池,也就是池中线程数总会P,如果N>P,则提交任务后任务会等待,直到有空闲线程为止
  3. newScheduledThreadPool创建一个固定大小的线程池,池内的线程会周期性地执行任务
  4. newSingleThreadExecutor创建一个单线程的线程池,任务按照指定顺序执行