Day2-go的并发编程 | 青训营笔记


前言

记录下字节青训营Day2的并发编程,这里自己并发的概念上的理解。如有不足欢迎指正。


一、并发与并行

  • 并发:
    并发是有关结构的,它是一种将一个程序分解成多个小片段并且每个小片段都可以独立执行的程序设计方法;并发程序的小片段之间一般存在通信联系并且通过通信相互协作

  • 并行:
    两个或两个以上事件(或线程)在同一时刻发生,是真正意义上的不同事件或线程在同一时刻,在不同CPU资源上(多核),同时执行。
    也因此并行,不存在像并发那样竞争,等待的概念。

再通俗点来说:
并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生

附上课程的图:

  • 多线程程序在一个核的cpu上运行(并发):
    在这里插入图片描述

  • 多线程在多个核的cpu上运行(并行):
    在这里插入图片描述

二、Go语言支持并发的设计

课程中提到了Go可以充分发挥多核优势,高效运行。但这只是go语言并发优势之一,这里再简单扩展一下。

2.1、Go语言采用轻量级协程并发模型

对于传统编程语言(C、C++)的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过pthread等函数库调用实现),操作系统负责调度。这种传统支持并发的方式主要有两大不足:复杂和难于扩展。

  • 复杂体现在:这些语言在创建的时候虽然很多参数,但是还是可以接受。但是一涉及到线程的同步或者是退出、就要考虑线程是否是分离的(detacked)、是否需要父线程去通知并等待子线程的的(同步)退出。
  • 难于扩展体现在:虽然线程的代价比进程小了很多,但我们依然不能大量创建线程,因为不仅每个线程占用的资源不小,操作系统调度切换线程的代价也不小。对于很多网络服务程序,由于不能大量创建线程,就要在少量线程里做网络的多路复用,即使用epoll/kqueue/IoCompletionPort这套机制。即便有了libevent、libev这样的第三方库的帮忙,写起这样的程序也是很不容易的,存在大量回调(callback),大大提高了编程难度。
  • 也因此Go果断放弃了传统的基于操作系统线程的并发模型而采用了用户层轻量级线程或者说是类协程(coroutine),Go将之称为goroutine。goroutine占用的资源非常少,Go运行时默认为每个goroutine分配的栈空间仅2KB。goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,在一个Go程序中可以创建成千上万个并发的goroutine。所有的Go代码都在goroutine中执行,哪怕是Go的运行时代码也不例外。
  • 一个Go程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine的存在。goroutine的调度全靠Go自己完成,实现Go程序内goroutine之间公平地竞争CPU资源的任务就落到了Go运行时头上。而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)。

2.2、从语法设计与机制层面

  • 对于常见的C++、C、Java:
    执行单元:线程。创建和销毁的方式:调用库函数或调用对象方法。并发线程间的通信:多基于操作系统提供的IPC机制,比如共享内存、Socket、Pipe等,当然也会使用有并发保护的全局变量。与上述传统语言相比,Go提供了语言层面内置的并发语法元素和机制。
  • 对于Go:
    执行单元:goroutine。创建和销毁方式:go+函数调用;函数退出即goroutine退出。并发goroutine的通信:通过语言内置的channel传递消息或实现同步,并通过select实现多路channel的并发控制。

因此对比来看go简单的多。

2.3、由程序员对并发原则的理解

由于goroutine的开销很小(相对线程),Go官方鼓励大家使用goroutine来充分利用多核资源。并发程序的结构设计不要局限于在单核情况下处理能力的高低,而要以在多核情况下充分提升多核利用率、获得性能的自然提升为最终目的。

结论:

并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计层面对程序进行拆解组合,再映射到程序执行层面:goroutine各自执行特定的工作,通过channel+select将goroutine组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让Go语言更适应现代计算环境。

三、进程、线程、协程

在这里的描述可能会从一些java的语义出发(笔者自身是javaer)。

3.1、何为进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

3.2、 何为线程?

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程,而Java本身就是多线程程序。

3.3、 何为协程?

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。从技术的角度来说,“协程就是你可以暂停执行的函数”。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

课程中也简单的提了个图:
在这里插入图片描述
但是这个图的说法其实是有误区的。
这里其实有用户态和内核态两个状态这里简单的复习一下:

  • 内核态其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。

  • 用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。

因此上文也提到了:一个Go程序对于操作系统来说只是一个用户层程序。因此它算用户态。
但是对于线程来说,它不一定属于内核态,因为还存在着用户态线程。

用户态线程也称作用户级线程(User Level Thread)。操作系统内核并不知道它的存在,它完全是在用户空间中创建

用户态线程优势:

  • 管理开销小:创建、销毁不需要系统调用。
  • 切换成本低:用户空间程序可以自己维护,不需要走操作系统调度。

用户态线程劣势:

  • 与内核协作成本高:比如这种线程完全是用户空间程序在管理,当它进行 I/O 的时候,无法利用到内核的优势,需要频繁进行用户态到内核态的切换。

  • 线程间协作成本高:设想两个线程需要通信,通信需要 I/O,I/O 需要系统调用,因此用户态线程需要支付额外的系统调用成本。

  • 无法利用多核优势:比如操作系统调度的仍然是这个线程所属的进程,所以无论每次一个进程有多少用户态的线程,都只能并发执行一个线程,因此一个进程的多个线程无法利用多核的优势。

  • 操作系统无法针对线程调度进行优化:当一个进程的一个用户态线程阻塞(Block)了,操作系统无法及时发现和处理阻塞问题,它不会更换执行其他线程,从而造成资源浪费。

内核态线程也称作内核级线程(Kernel Level Thread)。这种线程执行在内核态,可以通过系统调用创造一个内核级线程。

内核级线程:优势。

  • 可以利用多核 CPU 优势:内核拥有较高权限,因此可以在多个 CPU 核心上执行内核线程。
  • 操作系统级优化:内核中的线程操作 I/O 不需要进行系统调用;一个内核线程阻塞了,可以立即让另一个执行。

内核级线程:劣势。

  • 创建成本高:创建的时候需要系统调用,也就是切换到内核态。
  • 扩展性差:由一个内核程序管理,不可能数量太多。
  • 切换成本较高:切换的时候,也同样存在需要内核操作,需要切换内核态。

而对于进程来说,也自然是有用户态和内核态两种进程了。

3.4、协程与线程的区别

  • 地址空间:线程是进程内的一个执行单元,进程内至少有一个线程,它们共享进程的地址空间,而进程有自己独立的地址空间
  • 资源拥有**:进程是资源分配和拥有的单位**,同一个进程内的线程共享进程的资源
  • 线程是处理器调度的基本单位,但进程不是
  • 二者均可并发执行
  • 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制

3.5、协程与线程的区别:

  • 一个线程可以多个协程一个进程也可以单独拥有多个协程

  • 线程进程都是同步机制而协程则是异步。 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。

  • 线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。

  • 协程并不是取代线程, 而且抽象于线程之上, 线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行, 线程是协程的资源, 但协程不会直接使用线程, 协程直接利用的是执行器(Interceptor), 执行器可以关联任意线程或线程池, 可以使当前线程, UI线程, 或新建新程.。

  • 线程是协程的资源。协程通过Interceptor来间接使用线程这个资源。

3.6、go与java(通过线程与协程进行并发比较

  • 在Java中多线程之间是通过共享内存进行通信的,在go中多线程之间通信是基于消息的go中的通道是go中多线程通信的基石
  • 在java中创建的线程是与OS线程一一对应的,而在go中多个协程(goroutine)对应一个逻辑处理器,每个逻辑处理器与OS线程一一对应。
  • 每个线程要运行必须要在就绪状态情况下获取cpu,而操作系统是基于时间片轮转算法来调度线程占用cpu来执行任务的,每个OS线程被分配一个时间片来占用cpu进行任务的执行
  • 在java中由于创建的线程与os线程一一对应,所以java中的每个线程占用一个时间片来运行。而go中多个协程对应一个os 线程,也就是多个协程对应了一个时间片,go则使用自己的调度策略(非os的调度策略)来让多个协程使用一个时间片来并发的运行。也就是go中存在两级策略,一个是go语言层面的调度多个协程公用一个时间片,一个是os层面的调度多个逻辑处理器轮询占用不同的时间片。

猜你喜欢

转载自blog.csdn.net/weixin_45938441/article/details/124693355