golang面试:golang并发与多线程(三)


title: golang并发与多线程(三)
auther: Russshare
toc: true
date: 2021-07-13 18:57:01
tags: [golang, 面试, 多线程与并发]
categories: golang面试

  • 3、并发与多线程

    • 01 go语言的并发机制以及它所使用的CSP并发模型.Communicating Sequential Process

      • CSP模型是上个世纪七十年代提出的,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。 CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。

      • Golang中channel 是被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,其中 channel 是同步的一个消息被发送到 channel 中,最终是一定要被另外的实体消费掉的,在实现原理上其实类似一个阻塞的消息队列。

      • Goroutine 是Golang实际并发执行的实体,它底层是使用协程(goroutine)实现并发,goroutine是一种运行在用户态的用户线程,类似于 greenthread,go底层选择使用goroutine的出发点是因为,它具有以下特点:

        • 用户空间 避免了内核态和用户态的切换导致的成本。
        • 可以由语言和框架层进行调度。
        • 更小的栈空间允许创建大量的实例。
      • Golang中的Goroutine的特性:

        • Golang内部有三个对象: P对象(processor) 代表上下文(或者可以认为是cpu),M(work thread)代表工作线程,G对象(goroutine).

        正常情况下一个cpu对象启一个工作线程对象,线程去检查并执行goroutine对象。碰到goroutine对象阻塞的时候,会启动一个新的工作线程,以充分利用cpu资源。 所有有时候线程对象会比处理器对象多很多.

        • G(Goroutine) :我们所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息.
        • M(Machine) :对内核级线程的封装,数量对应真实的CPU数(真正干活的对象).
        • P(Processor) :即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过GOMAXPROCS()来设置,默认为核心数.
          • GPM调度模型
          • Golang是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言;也正是Go语言的并发特性,吸引了全球无数的开发者。

        Golang的CSP并发模型,是通过Goroutine和Channel来实现的。

        Goroutine 是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。 Channel是Go语言中各个并发结构体(Goroutine)之前的通信机制。通常Channel,是各个Goroutine之间通信的”管道“,有点类似于Linux中的管道。

        通信机制channel也很方便,传数据用channel <- data,取数据用<-channel。

        在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。

        而且不管传还是取,必阻塞,直到另外的goroutine传或者取为止。

    • 02 什么是channel,为什么它可以做到线程安全?

      • Channel是Go中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel也可以理解是一个先进先出的队列,通过管道进行通信。

      Golang的Channel,发送一个数据到Channel 和 从Channel接收一个数据 都是 原子性的。而且Go的设计思想就是:不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。也就是说,设计Channel的主要目的就是在多任务间传递数据的,这当然是安全的。

    • 03 无缓冲的 channel 和有缓冲的 channel 的区别?

      • 对于无缓冲的 channel,发送方将阻塞该信道,直到接收方从该信道接收到数据为止,而接收方也将阻塞该信道,直到发送方将数据发送到该信道中为止。
      • 对于有缓存的 channel,发送方在没有空插槽(缓冲区使用完)的情况下阻塞,而接收方在信道为空的情况下阻塞。
    • 04 什么是协程泄露(Goroutine Leak)?

      • 协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。常见的导致协程泄露的场景有以下几种:
        • 缺少接收器,导致发送阻塞

        • 缺少发送器,导致接收阻塞

        • 死锁(dead lock)

        • 两个或两个以上的协程在执行过程中,由于竞争资源或者由于彼此通信而造成阻塞,这种情况下,也会导致协程被阻塞,不能退出。

        • 无限循环(infinite loops)

        • 这个例子中,为了避免网络等问题,采用了无限重试的方式,发送 HTTP 请求,直到获取到数据。那如果 HTTP 服务宕机,永远不可达,导致协程不能退出,发生泄漏。

    • 05 Go 可以限制运行时操作系统线程的数量吗?

      • 可以使用环境变量 GOMAXPROCS 或 runtime.GOMAXPROCS(num int) 设置,例如:
        runtime.GOMAXPROCS(1) // 限制同时执行Go代码的操作系统线程数为 1

      • GOMAXPROCS 限制的是同时执行用户态 Go 代码的操作系统线程的数量,但是对于被系统调用阻塞的线程数量是没有限制的。
        GOMAXPROCS 的默认值等于 CPU 的逻辑核数,同一时间,一个核只能绑定一个线程,然后运行被调度的协程。

        因此对于 CPU 密集型的任务,若该值过大,例如设置为 CPU 逻辑核数的 2 倍,会增加线程切换的开销,降低性能。

        对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。

    • 06 GPM调度模型

      • 见(垃圾回收)
    • 07 Golang 中常用的并发模型?

      • 1、通过channel通知实现并发控制

        • 无缓冲的通道指的是通道的大小为0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送 goroutine 和接收 goroutine 同时准备好,才可以完成发送和接收操作。
        • 从上面无缓冲的通道定义来看,发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。这种无缓冲的通道我们也称之为同步通道。
        • 当主 goroutine 运行到 <-ch 接受 channel 的值的时候,如果该 channel 中没有数据,就会一直阻塞等待,直到有值。 这样就可以简单实现并发控制
      • 2、通过sync包中的WaitGroup实现并发控制

        • Goroutine是异步执行的,有的时候为了防止在结束mian函数的时候结束掉Goroutine,所以需要同步等待,这个时候就需要用 WaitGroup了,在 sync 包中,提供了 WaitGroup ,它会等待它收集的所有 goroutine 任务全部完成。在WaitGroup里主要有三个方法:

          • Add, 可以添加或减少 goroutine的数量.
          • Done, 相当于Add(-1).
          • Wait, 执行后会堵塞主线程,直到WaitGroup 里的值减至0
        • 在主 goroutine 中 Add(delta int) 索要等待goroutine 的数量。 在每一个 goroutine 完成后 Done() 表示这一个goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回返回。

        • 在Golang官网中对于WaitGroup介绍是A WaitGroup must not be copied after first use,在 WaitGroup 第一次使用后,不能被拷贝

      • 3、在Go 1.7 以后引进的强大的Context上下文,实现并发控制

        • 通常,在一些简单场景下使用 channel 和 WaitGroup 已经足够了,但是当面临一些复杂多变的网络并发场景下 channel 和 WaitGroup 显得有些力不从心了。 比如一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,这些 goroutine 又可能会开启其他的 goroutine,比如数据库和RPC服务。 所以我们需要一种可以跟踪 goroutine 的方案,才可以达到控制他们的目的,这就是Go语言为我们提供的 Context,称之为上下文非常贴切,它就是goroutine 的上下文。 它是包括一个程序的运行环境、现场和快照等。每个程序要运行时,都需要知道当前程序的运行状态,通常Go 将这些封装在一个 Context 里,再将它传给要执行的 goroutine 。

        • context 包主要是用来处理多个 goroutine 之间共享数据,及多个 goroutine 的管理。

          如果主协程需要在某个时刻发送消息通知子协程中断任务退出,那么就可以让子协程监听这个done channel,一旦主协程关闭done channel,那么子协程就可以推出了,这样就实现了主协程通知子协程的需求。这很好,但是这也是有限的。

          如果我们可以在简单的通知上附加传递额外的信息来控制取消:为什么取消,或者有一个它必须要完成的最终期限,更或者有多个取消选项,我们需要根据额外的信息来判断选择执行哪个取消选项。

          考虑下面这种情况:假如主协程中有多个任务1, 2, …m,主协程对这些任务有超时控制;而其中任务1又有多个子任务1, 2, …n,任务1对这些子任务也有自己的超时控制,那么这些子任务既要感知主协程的取消信号,也需要感知任务1的取消信号。

          如果还是使用done channel的用法,我们需要定义两个done channel,子任务们需要同时监听这两个done channel。嗯,这样其实好像也还行哈。但是如果层级更深,如果这些子任务还有子任务,那么使用done channel的方式将会变得非常繁琐且混乱。

          我们需要一种优雅的方案来实现这样一种机制:

          上层任务取消后,所有的下层任务都会被取消;
          中间某一层的任务取消后,只会将当前任务的下层任务取消,而不会影响上层的任务以及同级任务。
          这个时候context就派上用场了。我们首先看看context的结构设计和实现原理。

          • context 包的核心是 struct Context,接口声明如下:

          • Done() 返回一个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有一个取消信号

          • Err() 在Done() 之后,返回context 取消的原因。

          • Deadline() 设置该context cancel的时间点

          • Value() 方法允许 Context 对象携带request作用域的数据,该数据必须是线程安全的。

          • Context 对象是线程安全的,你可以把一个 Context 对象传递给任意个数的 gorotuine,对它执行 取消 操作时,所有 goroutine 都会接收到取消信号。

          • 一个 Context 不能拥有 Cancel 方法,同时我们也只能 Done channel 接收数据。 其中的原因是一致的:接收取消信号的函数和发送信号的函数通常不是一个。 典型的场景是:父操作为子操作操作启动 goroutine,子操作也就不能取消父操作。

          • Go服务器的每个请求都有自己的goroutine,而有的请求为了提高性能,会经常启动额外的goroutine处理请求,当该请求被取消或超时,该请求上的所有goroutines应该退出,防止资源泄露。那么context来了,它对该请求上的所有goroutines进行约束,然后进行取消信号,超时等操作。Context 的调用以链式存在,通过WithXxx方法派生出新的 Context与当前父Context 关联,当父 Context 被取消时,其派生的所有 Context 都将取消。

          • 在go语言编写的服务器当中,常常需要在gorutine中处理请求,比如一个http请求,通常会生成一个新的gorutine去进行处理,而这个gorutine当中或许还会新起许多gorutine,比如rpc服务的访问,数据库的访问,等等。当这个请求被取消或者超时的时候,此时这个链条上的gorutine应该被取消执行或者及时退出,以释放系统资源。亦或是这些gorutine需要共享授权令牌等等。有了context,取消,超时,共享链路信息,这一切都变得很简单。

          • 责任与边界
            context只是负责通知和传递信息,至于收到通知的gorutine如何做,那是它们自己的事情了。

    • 08 协程,线程,进程的区别。

      • 进程
        进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
      • 线程
        线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。
      • 协程
        协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

go1.5.4的源码中runtime/os1_linux.go中newosproc函数调用linux的clone系统API时,配置的flag包含_CLONE_THREAD。明显是一个普通线程。goroutine实际上是一个用户代码中的task,会通过调度机制放到 M (以linux 为例子OS Thread, 即一个普通线程user thread)中执行, go通过GPM模型,在OS Thread,即普通线程user thread之上调度用户程序中的tasks(goroutine),来实现M:N的调度模型。

猜你喜欢

转载自blog.csdn.net/weixin_45264425/article/details/132200028