Go并发编程实战 第三章 并发编程综述

发布时间 2023-07-15 00:46:41作者: CodeWater

经过前两章的基本认识,终于开始并发编程了。

并发编程基础

基本概念

  1. 串行和并行程序:串行程序特指只能被顺序执行的指令列表,并发程序则是可以被并发执行的两个及以上的串行程序的综合体。
  2. 并发和并行: 并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。(简单来记就是:并行是同时进行,一般是在cpu有多个的时候,一个cpu执行一个程序,达到同时进行的效果,是真正的同时;并发是时间段内发生,cpu前1毫秒执行这个程序后1毫秒执行另外一个,从宏观上来看两个程序是同时运行的,但实际不是)

同步异步

传递数据是并发程序内部的一种交互方式,也称为并发程序内部的通信。
通信方式有:同步和异步。

同步

  1. 同步的作用:是避免在并发访问共享资源时可能发生的冲突,以及确保有条不紊地传递数据
  2. 同步的原则:程序如果想使用一个共享资源,就必须先请求该资源并获取到对它的访问权。当程序不再需要某个资源的时候,它应该放弃对该资源的访问权(也称释放资源)。一个程序对资源的请求不应该导致其他正在访问该资源的程序中断,而应该等到 那个程序释放该资源之后再进行请求。也就是说,在同一时刻,某个资源应该只被一个程序占用

异步

异步: 这种方式使得数据可以不加延迟地发送给数据接收方。即使数据接收方还没有为接收数据做好准备,也不会造成数据发送方的等待。数据会被临时存放在一个称为通信缓存的数据结构中。通信缓存是一种特殊的共享资源,它可以同时被多个程序使用。数据接收方可以在准备就绪之后按照数据存人通信缓存的顺序接收它们。

多进程编程

IPC: 在多进程程序中,如果多个进程之间需要协作完成任务,那么这种进程间通信的方式就是需要重点考虑的事项之一。可以分为三大类:

  1. 基于通信的IPC方法。这种又可细分:
    • 以数据传送为手段的,包括:
      • 管道(pipe):用来传送字节流
      • 消息队列(message queue):用来传送结构化的消息对象
    • 以共享内存为手段的:主要以共享内存区(shared memeory)为代表,是最快的一种IPC方法
  2. 基于信号的IPC方法:也就是操作系统的信号(signal)机制,它是唯一的一种异步IPC方法。
  3. 基于同步的IPC方法:最重要的信号量(semaphore)

Go 支持的IPC方法有管道、信号、socket

进程

  1. 通常,我们把一个程序的执行称为一个进程。反过来讲,进程用于描述程序的执行过程。因此,程序和进程是一对概念,它们分别描述了一个程序的静态形式和动态特征。除此之外,进程还是操作系统进行资源分配的一个基本单位

  2. Unix/Linux操作系统中的每一个进程都有父进程。所有的进程共同组成了一个树状结构。内核启动进程作为进程树的根,负责系统的初始化操作,它是所有进程的祖先,它的父进程就是它自己。如果某一个进程先于它的子进程结束,那么这些子进程将会被内核启动进程“收养”,成为它的直接子进程。

  3. 进程标识:为了管理进程,内核必须对每个进程的属性和行为进行详细的记录,包括进程的优先级、状态、虚拟地址范围以及各种访问权限,等等。更具体地说,这些信息都会被记录在每个进程的进程描述符中。进程描述符并不是一个简单的符号,而是一个非常复杂的数据结构。保存在进程描述符中的进程ID(常称为PID)是进程在操作系统中的唯一标识,其中进程ID为1的进程就是之前提到的内核启动进程。进程ID是一个非负整数且总是顺序的编号,新创建的进程ID总是前一个进程ID递增的结果。此外,进程ID也可以重复使用。当进程ID达到其最大限值时,内核会从头开始查找闲置的进程ID并使用最先找到的那一个作为新进程的ID。另外,进程描述符中还会包含当前进程的父进程的ID(常称为PPID)。
    通过Go标准库代码包os可以来查看当前进程的PID和PPID,像这样:

    pid := os.Getpid()
    ppid := os.Getppid()
    
  4. 进程状态:在Liux操作系统中,每个进程在每个时刻都是有状态的。可能的状态共有6个,分别是:可运行状态、可中断的睡眠状态、不可中断的睡眠状态、暂停状态或跟踪状态、僵尸状态和退出状态,下面简要说明一下。

    关于这部分(书上按照Linux来讲的),不过我之前看到不同语言之间的定义好像也不一样,之前学过OS和Java,发现Java明显少一些状态,不过大差不差,总的来说没啥问题,不过面试的时候可能面试官就会介意??!!

    • 可运行状态(TASK_RUNNING,简称为R)。如果一个进程处在该状态,那么说明它立刻要或正在CPU上运行。不过运行的时机是不确定的,这由进程调度器来决定。
    • 可中断的睡眠状态(TASK_INTERRUPTIBLE,简称为S)。当进程正在等待某个事件(比如网络连接或信号量)到来时,会进入此状态。这样的进程会被放人对应事件的等待队列中。当事件发生时,对应的等待队列中的一个或多个进程就会被唤醒
    • 不可中断的睡眠状态(TASK_UNINTERRUPTIBLE,简称为D)。此种状态与可中断的睡眠状态的唯一区别就是它不可被打断。这意味着处在此种状态的进程不会对任何信号作出响应。更确切地讲,发送给此状态的进程的信号直到它从该状态转出才会被传递过去。处于此状态的进程通常是在等待一个特殊的事件,比如等待同步的I/O操作(磁盘I/O等)完成。
    • 暂停状态或跟踪状态(TASK_STOPPED或TASK_TRACED,简称为T)。向进程发送SIGSTOP信号,就会使该进程转入暂停状态,除非该进程正处于不可中断的睡眠状态。向正处于暂停状态的进程发送SIGCONT信号,会使该进程转向可运行状态。处于该状态的进程会暂停,并等待另一个进程(跟踪它的那个进程)对它进行操作。例如,我们使用调试工具GDB在某个程序中设置一个断点,而后对应的进程在运行到该断点处就会停下来。这时,该进程就处于跟踪状态。跟踪状态与暂停状态非常类似。但是,向处于跟踪状态的进程发送SIGCONT信号并不能使它恢复。只有当调试进程进行了相应的系统调用或者退出后,它才能够恢复。
    • 僵尸状态(TASK_DEAD-EXIT_ZOMBIE,简称为Z)。处于此状态的进程即将结束运行,该进程占用的绝大多数资源也都已经被回收,不过还有一些信息未删除,比如退出码以及一些统计信息。之所以保留这些信息,主要是考虑到该进程的父进程可能需要它们。由于此时的进程主体已经被删除而只留下一个空壳,故此状态才称为僵尸状态。
    • 退出状态(TASK_DEAD-EXIT_DEAD,简称为X)。在进程退出的过程中,有可能连退出码和统计信息都不需要保留。造成这种情况的原因可能是显式地让该进程的父进程忽略掉SIGCHLD信号(当一个进程消亡的时候,内核会给其父进程发送SIGCHLD信号以告知此情况),也可能是该进程已经被分离(分离即让子进程和父进程分别独立运行)。分离后的子程序将不会再使用和执行与父进程共享的代码段中的指令,而是加载并运行一个全新的程序。在这些情况下,该进程在退出的时候就不会转人僵尸状态,而会直接转入退出状态。处于退出状态的进程会立即被干净利落地结束掉,它占用的系统资源也会被操作系统自动回收。
      进程状态转换:
  5. 系统调用过程中的CPU状态切换和流程控制:

  6. 执行过程中不能中断的操作称为原子操作(atomic operation),而只能被串行化访问或执行的某个资源或某段代码称为临界区(critical section)。

    所有的系统调用都属于原子操作

  7. 相比原子操作,让串行化执行的若干代码形成临界区的这种做法更加通用。保证只有一个进程或线程在临界区之内的做法有一个官方称谓一互斥(mutual exclusion,简称mutex)。

管道

管道(pipe)是一种半双工(或者说单向)的通信方式,只能用于父进程与子进程以及同祖先的子进程之间的通信。例如,在使用shell命令的时候,常常会用到管道:

ps aux | grep go

shell为每个命令都创建一个进程,然后把左边命令的标准输出用管道与右边命令的标准输入连接起来。管道的优点在于简单,而缺点则是只能单向通信以及对通信双方关系上的严格限制。

对于管道,Go是支持的。通过标准库代码包os/exec中的API,我们可以执行操作系统命令并在此之上建立管道。下面创建一个exec.Cmd类型的值:cmdo := exec.Command("echo","-n","My first command comes from golang."),对应的shell命令:echo -n "My first command comes from golang."

信号

todo:讲的有点晦涩,没怎么看懂。。。。后面二刷一遍吧,然后补上代码例子的讲解。
操作系统信号(signal,以下简称信号)是IPC中唯一一种异步的通信方法,它的本质是用软件来模拟硬件的中断机制。信号用来通知某个进程有某个事件发生了。例如,在命令行终端按下某些快捷键,就会挂起或停止正在运行的程序。
每一个信号都有一个以“SIG”为前缀的名字,例如SIGINT、SIGQUIT以及SIGKILL,等等。但是,在操作系统内部,这些信号都由正整数表示,这些正整数称为信号编号。在Linux的命令行终端下,我们可以使用kill -l命令来查看当前系统所支持的信号。

可以看到,Liux支持的信号有62种(注意,没有编号为32和33的信号)。其中,编号从1到31的信号属于标准信号(也称为不可靠信号),而编号从34到64的信号属于实时信号(也称为可靠信号)。对于同一个进程来说,每种标准信号只会被记录并处理一次。并且,如果发送给某一个进程的标准信号的种类有多个,那么它们的处理顺序也是完全不确定的。而实时信号解决了标准信号的这两个问题,即多个同种类的实时信号都可以记录在案,并且它们可以按照信号的发送顺序被处理。虽然实时信号在功能上更为强大,但是已成为事实标准的标准信号也无法被替换掉。因此,这两大类信号一直共存着。

  • 简单来说,信号的来源有键盘输入(比如按下快捷键Ctrl-℃)、硬件故障、系统函数调用和软件中的非法运算。进程响应信号的方式有3种:忽略、捕捉和执行默认操作。
    Go命令会对其中的一些以键盘输入为来源的标准信号作出响应,这是通过标准库代码包os/signal中的一些API实现的。更具体地讲,Go命令指定了需要被处理的信号并用一种很优雅的方式(用到了通道类型)来监听信号的到来。
    下面就从接口类型os.Signal开始讲起,该类型的声明如下:
type Signal interface
String()string
Signal()//to distinguish from other Stringers

从os.Signal接口的声明可知,其中的Signa1方法的声明并没有实际意义。它只是作为os.Signal接口类型的一个标识。因此,在Go标准库中,所有实现它的类型的Signal方法都是空方法(方法体中没有任何语句)。所有实现此接口类型的值都可以表示一个操作系统信号。在G0标准库中,已经包含了与不同操作系统的信号相对应的程序实体。具体来说,标准库代码包syscall中有与不同操作系统所支持的每一个标准信号对应的同名常量(以下简称信号常量)。这些信号常量的类型都是syscal1.Signal的。syscal1.Signal是os.Signal接口的一个实现类型,同时也是一个it类型的别名类型。也就是说,每一个信号常量都隐含着一个整数值,并且都与它所表示的信号在所属操作系统中的编号一致。
另外,如果查看syscall.Signal类型的String方法的源代码,还会发现一个包级私有的、名为signals的变量。在这个数组类型的变量中,每个索引值都代表一个标准信号的编号,而对应的元素则是针对该信号的一个简短描述,这些描述会分别出现在那些信号常量的字符串表示形式中。

socket

socket,常译为套接字,也是一种IPC方法。但是与其他IPC方法不同的是,它可以通过网络连接让多个进程建立通信并相互传递数据,这使得通信双方是否在同一台计算机上变得无关紧要。实际上,这是socket的目标之使通信端的位置透明化。

socket类型


数据形式有两种:数据报和字节流。

  • 以数据报为数据形式意味着数据接收方的socket接口程序可以意识到数据的边界并会对它们进行切分,这样就省去了接收方的应用程序寻找数据边界和切分数据的工作量。
  • 以字节流为数据形式的数据传输实际上传输的是一个字节接着一个字节的串,我们可以把它想象成一个很长的字节数组。一般情况下,字节流并不能体现出哪些字节属于哪个数据包。因此,socket接口程序是无法从中分离出独立的数据包的,这一工作只能由应用程序去完成。然而,SOCK_SEOPACKET类型的socket接口程序是例外的。数据发送方的socket接口程序可以忠实地记录数据边界。这里的数据边界就是应用程序每次发送的字节流片段之间的分界点,这些数据边界信息会随着字节流一同发往数据接收方。数据接收方的socket:接口程序会根据数据边界把字节流切分成(或者说还原成)若干个字节流片段并按照需要依次传递给应用程序。

在面向有连接的socket之间传输数据之前,必须先建立逻辑连接。在连接建好之后,通信双方可以很方便地互相传输数据。并且,由于连接已经暗含了双方的地址,所以在传输数据的时候不必再指定目标地址。两个面向有链接的socket之间一旦建立连接,那么它们发送的数据就只能发送到连接的另一端。然而,面向无连接的socket则完全不同,这类socket在通信时无需建立连接。它们传输的每一个数据包都是独立的,并且会直接发送到网络上。这些数据包中都含有目标地址,因此每个数据包都可能传输至不同的目的地。此外,在面向无连接的socket上,数据流只能是单向的。也就是说,我们不能使用同一个面向无连接的socket实例既发送数据又接收数据。

最后要注意,SOCK RAW类型的socket提供了一个可以直接通过底层(TCP/IP协议栈中的网络互联层)传送数据的方法。为了保证安全性,应用程序必须具有操作系统的超级用户权限才能够使用这种方式。并且,该方法的使用成本也相对较高,因为应用程序一般需要自己构建数据传输格式(像TCP/IP协议栈中TCP协议的数据段格式和UDP协议的数据报格式那样)。因此,应用程序一般极少使用这种类型的socket。

  • socket接口与TCP/IP协议栈、操作系统内核的关系:

用go实现一个socket编程的例子

这部分作者讲的很细,基本上是从流程方面一步一步来,连涉及到的协议及底层也给捋了一遍,感兴趣的可以看看原书,笔者这里就捡一些重要的记录。

基本概念

流程图:

这张流程图展现了TCP服务端和TCP客户端通过操作系统的socket接口建立TCP连接并进行通信的一般情形。这只是一个简单的通信流程,客户端程序和服务端程序建立连接后只交换了一次数据。在实际的应用场景中,通信双方会进行多次数据交换。需要说明的是,图中虚线框之内的子流程一般会循环很多次。

在G0中,协议由一些字符串字面量来表示:

需要说明的是,这个参数所代表的必须是面向流的协议。TCP和SCTP都属于面向流的传输层协议,但不同的是,TCP协议实现程序无法记录和感知任何消息边界,也无法从字节流分离出消息,而SCTP协议实现程序却可以做到这一点。

  • Go的socket编程API程序在一定程度上充当了前面所说的应用程序的角色,它为我们屏蔽了相关系统调用的EAGAIN错误,这使得有些socket编程API调用起来像是阻塞式的。但是,我们应该明确,它在底层使用的是非阻塞式的socket接口。
代码

todo: 待补充

多线程编程

注意区别多进程,线程更加的轻量
G0并发编程模型在底层是由操作系统所提供的线程库支撑的,因此这里很有必要先介绍一下多线程编程。
线程可以视为进程中的控制流。一个进程至少会包含一个线程,因为其中至少会有一个控制流持续运行。因而,一个进程的第一个线程会随着这个进程的启动而创建,这个线程称为该进程的主线程。当然,一个进程也可以包含多个线程。这些线程都是由当前进程中已存在的线程创建出来的,创建的方法就是调用系统调用,更确切地说是调用pthread create函数。拥有多个线程的进程可以并发执行多个任务,并且即使某个或某些任务被阻塞,也不会影响其他任务正常执行,这可以大大改善程序的响应时间和吞吐量。另一方面,线程不可能独立于进程存在。它的生命周期不可能逾越其所属进程的生命周期。

基本概念

  1. 线程标识:和进程一样,每个线程也都有属于自己的ID,这类D也称为线程ID或者TID。但与进程不同,线程D在系统范围内可以不唯一,而只在其所属进程的范围内唯一。不过,Linux系统的线程实现则确保了每个线程ID在系统范围内的唯一性,并且当线程不复有在后,其线程D可以被其他线程复用。
  2. 线程间的控制:如前文所述,系统中的每个进程都有它的父进程,而由某个进程创建出来的进程都称为该进程的直接子进程。与这种家族式的树状结构不同,同一个进程中的任意两个线程之间的关系都是平等的,它们之间并不存在层级关系。任何线程都可以对同一进程中的其他线程进行有限的管理,这里所说的有限的管理主要有以下4种:
    1. 创建线程。主线程在其所属进程启动时创建,因此,它的创建并不在此论述范围内,这里仅指对其他线程的创建。我已经说过,任何线程都可以通过调用系统调用pthread_create来创建新的线程。为了言简意赅,自此我把调用系统调用或函数的线程简称为调用线程。在创建新线程时,调用线程需要给定新线程将要执行的函数以及传入该函数的参数值。由于代表该函数的参数被命名为start,因此常称为start函数。start函数是可以有返回值的。我们可以在其他线程中通过与新线程的连接得到在该新线程中执行的start函数的返回值。如果新线程创建成功,调用线程会得到新线程的ID。
    2. 终止线程。线程可以通过多种方式终止同一进程中的其他线程。其中一种方式就是调用系统调用pthread_cancel,该函数的作用是取消掉给定的线程ID代表的那个线程。更明确地讲,它会向目标线程发出一个请求,要求它立即终止执行。但是,该函数只是发送请求并立即返回,而不会等待目标线程对该请求做出响应。至于目标线程什么时候对此请求做出响应、做出怎样的响应,则取决于另外的因素(比如目标线程的取消状态及类型)。在默认情况下,目标线程总是会接受线程取消请求,不过等到时机成熟(执行到某个取消点)的时候,目标线程才会去响应线程取消请求。
    3. 连接已终止的线程。此操作由系统调用pthread_join来执行,该函数会一直等待与给定的线程ID对应的那个线程终止,并把该线程执行的start函数的返回值告知调用线程。如果目标线程已经处于终止状态,那么该函数会立即返回。这就像是把调用线程放置在了目标线程的后面,当目标线程把流程控制权交出时,调用线程会接过流程控制权并继续执行pthread joini函数调用之后的代码。这也是把这一操作称为“连接”的缘由之一。实际上,如果一个线程可被连接,那么在它终止之时就必须连接,否则就会变成一个僵尸线程。僵尸线程不但会导致系统资源浪费,还会无意义地减少其所属进程的可创建线程数量.
    4. 分离线程。将一个线程分离意味着它不再是一个可连接的线程。而在默认情况下,一个线程总可以被其他线程连接。分离操作的另一个作用是让操作系统内核在目标线程终止时自动进行清理和销毁工作。注意,分离操作是不可逆的。也就是说,我们无法使一个不可连接的线程变回到可连接的状态。不过,对于一个已处于分离状态的线程,执行终止操作仍然会起作用。分离操作由系统调用pthread_detach来执行,它接受一个代表了线程D的参数值。
  3. 线程的状态