Goroutines & Thread & Process

Go语言最大的特色就是从语言层面支持并发(Goroutine),Goroutine是Go中最基本的执行单元。事实上每一个Go程序至少有一个Goroutine:主Goroutine。当程序启动时,它会自动创建。

进程 Process

进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;

进程间通信的方式

进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket。

管道

管道主要包括匿名管道和命名管道:管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信

信号signal

  • 信号是Linux系统中用于进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态。
  • 如果该进程当前并未处于执行状态,则该信号就有内核保存起来,知道该进程回复执行并传递给它为止。
  • 如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消是才被传递给进程。

Linux系统中常用信号:
(1)SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
(2)SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号。
(3)SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\\键将产生该信号。
(4)SIGBUS和SIGSEGV:进程访问非法地址。
(5)SIGFPE:运算中出现致命错误,如除零操作、数据溢出等。
(6)SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号。
(7)SIGTERM:结束进程信号。shell下执行kill 进程pid发送该信号。
(8)SIGALRM:定时器信号。
(9)SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程。

共享内存

共享内存可以说是Linux下最快速、最有效的进程间通信方式。两个不同进程A、B共享内存的意思是,同一块物理内存被映射到进程A、B各自的进程地址空间,进程A可以及时看到进程B对内存中数据的更新,反之,B也可以看到A的修改。

  • 显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的复制
    (对于管道和消息队列等通信方式,需要在内核空间和用户空间进行四次的数据复制,而共享内存则只复制两次:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程间共享内存,并不是使用后就接触映射,而是保持共享区,直至通信完毕)
  • 最大不足在于,由于多个进程对同一内存区域就具有访问的权限,各个进程之间的同步问题显得尤为重要,通常与信号量结合使用解决同步问题

socket

套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行。也就是说它可以让不在同一台计算机但通过网络连接计算机上的进程进行通信。

套接字是支持TCP/IP的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。

套接字特性
套接字的特性由3个属性确定,它们分别是:域、端口号、协议类型。

原始套接字与标准套接字的区别在于:
原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取TCP协议的数据,数据报套接字只能读取UDP协议的数据。因此,如果要访问其他协议发送数据必须使用原始套接字。

线程 Thread

有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。是进程的子任务,是CPU调度和分派的基本单位。是操作系统可识别的最小执行和调度单位。用于保证程序的实时性,实现进程内部的并发;线程的切换一般也由操作系统调度。

一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。即线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。

用于保证程序的实时性,实现进程内部的并发;线程是操作系统可识别的最小执行和调度单位。

线程池大小/线程数量 & CPU核心数

一个CPU核心,单位时间内只能执行一个线程的指令。

那么理论上,一个线程只需要不停的执行指令,就可以跑满一个核心的利用率。

现代CPU基本都是多核心的,如果要执行的线程大于核心数,那么就需要通过操作系统的调度了。操作系统给每个线程分配CPU时间片资源,然后不停的切换,从而实现并行执行的效果。

如果每个线程都不停的执行指令,不给CPU空闲的时间,并且同时执行的线程数大于CPU的核心数,就会导致操作系统更频繁的执行切换线程执行,以确保每个线程都可以得到执行。

不过切换是有代价的,每次切换会伴随着寄存器数据更新,内存页表更新等操作。虽然一次切换的代价和I/O操作比起来微不足道,但如果线程过多,线程切换的过于频繁,甚至在单位时间内切换的耗时已经大于程序执行的时间,就会导致CPU资源过多的浪费在上下文切换上,而不是在执行程序,得不偿失。

大多程序在运行时都会有一些 I/O操作,可能是读写文件,网络收发报文等,这些 I/O 操作在进行时时需要等待反馈的。比如网络读写时,需要等待报文发送或者接收到,在这个等待过程中,线程是等待状态,CPU没有工作。此时操作系统就会调度CPU去执行其他线程的指令,这样就完美利用了CPU这段空闲期,提高了CPU的利用率。

总结:

  1. 一个极端的线程(不停执行“计算”型操作时),就可以把单个核心的利用率跑满,多核心CPU最多只能同时执行等于核心数的“极端”线程数
  2. 如果每个线程都这么“极端”,且同时执行的线程数超过核心数,会导致不必要的切换,造成负载过高,只会让执行更慢
  3. I/O 等暂停类操作时,CPU处于空闲状态,操作系统调度CPU执行其他线程,可以提高CPU利用率,同时执行更多的线程
  4. I/O 事件的频率频率越高,或者等待/暂停时间越长,CPU的空闲时间也就更长,利用率越低,操作系统可以调度CPU执行更多的线程

在实际的程序中,或者说一些Java的业务系统中,线程数(线程池大小)规划多少合适呢?

没有固定答案,先设定预期,比如期望的CPU利用率在多少,负载在多少,GC频率多少之类的指标后,再通过测试不断的调整到一个合理的线程数

协程 coroutine

又称微线程与子例程(或者称为函数)一样,协程(coroutine)也是一种程序组件。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。

和线程类似,共享堆,不共享栈,协程的切换一般由程序员在代码中显式控制。它避免了上下文切换的额外耗费,兼顾了多线程的优点,简化了高并发程序的复杂。

Goroutine和其他语言的协程(coroutine)在使用方式上类似,但从字面意义上来看不同,一个是Goroutine,一个是coroutine,再就是协程是一种协作任务控制机制,在最简单的意义上,协程不是并发的,而Goroutine支持并发的。因此Goroutine可以理解为一种Go语言的协程。同时它可以运行在一个或多个线程上。

Go 协程切换时机

Golang管理协程时也必然会涉及到协程之间的切换:阻塞的协程被切换出去,可运行的协程被切换进来。

出现阻塞,意味 CPU 在当前执行域没活干了,换 go 而言,调度器要 goroutine 上下文切换。至于切换到哪个 goroutine,由调度器决定。但可以肯定的是对同一chan所相关的goroutine执行有序

  1. select操作阻塞时

  2. io阻塞

  3. 阻塞在channel:chan 读写出现阻塞时,runtime 会隐式地进行上下文切换

  4. 程序员显式编码操作,如下:

    显式交给调度器切换,否则有的goroutine就饿死了

    runtime.Gosched()

  5. 等待锁

  6. 程序调用:time.Sleep(3 * time.Second)

Q&A

进程与线程

进程是操作系统进行资源分配的基本单位,每个进程都有自己的独立内存空间。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

线程又叫做轻量级进程,是进程的一个实体,是处理器任务调度和执行的基本单位位。它是比进程更小的能独立运行的基本单位。线程只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。

对于操作系统来说,一个任务就是一个进程(Process)。比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程,打开一个Word就启动了一个Word进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,进程内的这些“子任务”称为线程(Thread)。

由于每个进程至少要干一件事,所以,一个进程至少有一个线程。当然,像Word这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。

协程

协程,又称微线程,是一种用户态的轻量级线程,协程的调度完全由用户控制(也就是在用户态执行)。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到线程的堆区,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和线程切换相比,线程数量越多,协程的性能优势就越明显。不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。此外,一个线程的内存在MB级别,而协程只需要KB级别。

进程和线程的区别

每个线程都是一个轻量级进程(Light Weight Process),都有自己的唯一PID和一个TGID(Thread group ID)。TGID是启动整个进程的thread的PID。

例如,当一个进程被创建的时候,它其实是一个PID和TGID数值相同线程。当线程A启动线程B时,线程B会有自己的唯一PID,但它的TGID会从A继承而来。这样通过PID线程可以独立得到调度,而相同的TGID可以知道哪些线程属于同一个进程,这样可以共享资源(RAM,虚拟内存、文件等)。

线程进程的区别体现在6个方面:

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
  • 资源开销:每个进程都有独立的代码和数据空间,程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一进程的线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
  • 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的。
  • 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的。
  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
  • 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。两者均可并发执行。

协程与线程的区别

  1. 一个线程可以有多个协程。
  2. 大多数业务场景下,线程进程可以看做是同步机制,而协程则是异步。
  3. 线程是抢占式,而协程是非抢占式的,所以需要用户代码释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
  4. 协程并不是取代线程,而且抽象于线程之上。线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行。

reference

第十章 进程、线程与 goroutine · Go语言标准库