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

经过前两章的基本认识,终于开始并发编程了。
2023-7-15:看完这章,大概对书的内容的有一个清晰的认识,基本上涉及底层os的内容都是基于Linux。

并发编程基础

基本概念

  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接口的一个实现类型,同时也是一个int类型的别名类型。也就是说,每一个信号常量都隐含着一个整数值,并且都与它所表示的信号在所属操作系统中的编号一致。
另外,如果查看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函数。拥有多个线程的进程可以并发执行多个任务,并且即使某个或某些任务被阻塞,也不会影响其他任务正常执行,这可以大大改善程序的响应时间和吞吐量。另一方面,线程不可能独立于进程存在。它的生命周期不可能逾越其所属进程的生命周期。

基本概念

线程标识

和进程一样,每个线程也都有属于自己的ID,这类D也称为线程ID或者TID。但与进程不同,线程D在系统范围内可以不唯一,而只在其所属进程的范围内唯一。不过,Linux系统的线程实现则确保了每个线程ID在系统范围内的唯一性,并且当线程不复有在后,其线程D可以被其他线程复用。

线程间的控制

如前文所述,系统中的每个进程都有它的父进程,而由某个进程创建出来的进程都称为该进程的直接子进程。与这种家族式的树状结构不同,同一个进程中的任意两个线程之间的关系都是平等的,它们之间并不存在层级关系。任何线程都可以对同一进程中的其他线程进行有限的管理,这里所说的有限的管理主要有以下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个,分别是:用户级线程模型、内核级线程模型和两级线程模型。它们之间最大的差异就在于线程与内核调度实体(Kernel Scheduling Entity,简称KSE)之间的对应关系上。顾名思义,内核调度实体就是可以被内核的调度器调度的对象。在很多文献和书中,它也称为内核级线程,是操作系统内核的最小调度单元

  • 用户级线程模型。此模型下的线程是由用户级别的线程库全权管理的。线程库并不是内核的一部分,而只是存储在进程的用户空间之中,这些线程的存在对于内核来说是无法感知的。显然,这些线程也不是内核的调度器的调度对象。对线程的各种管理和协调完全是用户级程序的自主行为,与内核无关。应用程序在对线程进行创建、终止、切换或同步等操作的时候,并不需要让CPU从用户态切换到内核态。从这方面讲,用户级线程模型确实在线程操作的速度上存在优势。并且,由于对线程的管理完全不需要内核的参与,所以使得程序的移植性更强一些。但是,这一特点导致在此模型下的多线程并不能够真正并发运行 。例如,如果线程在I/O操作过程中被阻塞,那么其所属进程也会被阻塞。这正是由线程无法被内核调度造成的。在调度器的眼里,进程是一个无法再被分割的调度单元,无论其中存在多少个线程。另外,即使计算机上存在多个CPU,进程中的多个线程也无法被分配给不同的CPU运行。对于CPU的负载均衡来说,进程的粒度太粗了。因而让不同的进程在不同的CPU上运行的意义也微乎其微。显然,线程的所谓优先级也会形同虚设。同一个进程中所有线程的优先级只能由该进程的优先级来体现。同时,线程库对线程的调度完全不受内核控制,它与内核为进程设定的优先级是没有关系的。正因为用户级线程模型存在这些严重的缺陷, 所以现代操作系统都不使用这种模型来实现线程。但是,在早期,以这种模型作为线程实现方式的案例确实存在。由于包含了多个用户级线程的进程只与一个KSE相对应,因此这种线程实现模型又称为多对一(M:1)的线程实现。
  • 内核级线程模型。该模型下的线程是由内核负责管理的,它们是内核的一部分。
    应用程序对线程的创建、终止和同步都必须通过内核提供的系统调用来完成。进程中的每一个线程都与一个KSE相对应。也就是说,内核可以分别对每一个线程进行调度。由此,内核级线程模型又称为一对一(1:1)的线程实现。一对一线程实现消除了多对一线程实现的很多弊端,可以真正实现线程的并发运行。因为这些线程完全由内核来管理和调度。正如前文所述,内核可以在不同的时间片内让CPU运行不同的线程。内核在极短的时间内快速切换和运行各个线程,使得它们看起来就像正在同时运行。即使进程中的一个线程由于某种原因进入到阻塞状态,其他线程也不会受到影响,这也使得内核在多个CPU上进行负载均衡变得容易和有效。当然,如果一个线程与被阻塞的线程之间存在同步关系,那么它也可能受到牵连。但是,这是一种应用级别的干预,并不属于线程本身的特质。同时,内核对线程的全权接管使操作系统在库级别几乎无需为线程管理做什么事情。这与用户级别线程模型形成了鲜明的对比。但是,内核线程的管理成本显然要比用户级线程高出很多。线程的创建会用到更多的内核资源。并且,像创建线程、切换线程以及同步线程这类操作所花费的时间也会更多。如果一个进程包含了大量的线程,那么它会给内核的调度器造成非常大的负担,甚至会影响到操作系统的整体性能。因此,采用内核级线程模型的操作系统对一个进程中可以创建的线程的数量都有直接或间接的限制。尽管内核级线程模型有资源消耗较大、调度速度较慢等缺点,但是与用户级线程的实现方式相比,它还是有较大优势的。很多现代操作系统都是以内核级线程模型实现线程的,包括Liux操作系统。
  • 两级线程模型。两级线程模型的目标是取前两种模型之精华,并去二者之糟粕,也称为多对多(M:N)的线程实现。与其他模型相比,两级线程模型提供了更多的灵活性。在此模型下,一个进程可以与多个KSE相关联,这与内核级线程模型相似。但与内核级线程模型不同的是,进程中的线程(以下称为应用程序线程)并不与KSE一一对应,这些应用程序线程可以映射到同一个已关联的KSE上。首先,实现了两级线程模型的线程库会通过操作系统内核创建多个内核级线程。然后,它会通过这些内核级线程对应用程序线程进行调度。大多数此类线程库都可以将这些应用程序线程动态地与内核级线程关联。这样的设计显然使线程的管理工作更加复杂,因为这需要内核和线程库的共同努力和协作才能正确、有效地进行。但是,也是由于这样的设计,内核资源的消耗才得以大大减少,同时也使线程管理操作的效能提高了不少。 因为两级线程模型实现的复杂性,它往往不会被操作系统内核的开发者所采用。但是,这样的模型却可以很好地在编程语言层面上实现并充分发挥出其应有的作用。就拿G0来说,其并发编程模型就与两级线程模型在理念上非常类似,只不过它的具体实现方式更加高级和优雅一些。在G0的并发编程模型中,不受操作系统内核管理的独立控制流并不叫作应用程序线程或者线程,而称为goroutine(也可以称为Go例程)。3种线程实现模型如图所示。

线程同步

总的来说,在多个线程之间采取同步措施,无非是为了让它们更好地协同工作或者维持共享数据的一致性。

  1. 共享数据的一致性: 包含多个线程的程序(以下简称多线程程序)多以共享数据作为线程之间传递数据的手段。由于一个进程所拥有的相当一部分虚拟内存地址都可以被该进程中的所有线程共享,所以这些共享数据大多是以内存空间作为载体的。如果两个线程同时读取同一块共享内存但获取到的数据却不同,那么程序很可能会出现某种错误。这是因为,共享数据的一致性往往代表着某种约定,而只有在该约定成立的前提下,多线程程序中的各个线程才能够使相应的流程执行正确。
  2. 互斥量: 在同一时刻,只允许一个线程处于临界区之内的约束称为互斥(mutex)。每个线程在进入临界区之前,都必须先锁定某个对象,只有成功锁定对象的线程才会允许进人临界区,否则就会阻塞,这个对象称为互斥对象或互斥量。
  3. 条件变量: 在可用的同步方法集中,还有一个可以与互斥量相提并论的同步方法——条件变量。与互斥量不同,条件变量的作用并不是保证在同一时刻仅有一个线程访问某一个共享数据,而是在对应的共享数据的状态发生变化时,通知其他因此而被阻塞的线程。条件变量总是与互斥量组合使用。当线程成功锁定互斥量并访问到共享数据时,共享数据的状态并不一定正好满足它的要求。
  4. 线程安全性: 在本节的最后,我来简要说一下线程安全性这个概念。如果有一个代码块,它可以被多个线程并发执行,且总能够产生预期的结果,那么该代码块就是线程安全的thread-safe)。
posted @   CodeWater  阅读(48)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· Qt个人项目总结 —— MySQL数据库查询与断言
历史上的今天:
2022-07-15 Idea2019.3 :一直卡在Resolving Maven dependencies
2022-07-15 Centos7: 防火墙相关
2022-07-15 Zookeeper-3.4.9安装
点击右上角即可分享
微信分享提示