GO并发编程 | 青训营笔记

91 阅读8分钟

并发与并行

并发(Concurrency)和并行(Parallelism)是并发编程中两个重要的概念,它们有着明显的区别:

  • 并发指的是多个任务在同一时间段内交替进行,通过任务间的切换来实现多个任务的执行。在并发中,任务之间可以是相互独立的,彼此不需要同时执行,只需在一定时间内交替执行。
  • 并行指的是多个任务同时执行,每个任务在不同的处理器上运行,并同时进行。在并行中,任务之间必须是同时进行的,通过利用多核处理器或分布式系统的能力来实现任务的并行执行。

并发编程的基本概念

  1. 线程(Thread):线程是操作系统调度的最小执行单位,一个进程可以包含多个线程。多个线程可以在同一进程内共享相同的内存空间,因此可以方便地共享数据和通信。

  2. 进程(Process):进程是正在运行的程序实例,每个进程都有独立的内存空间和系统资源。不同进程之间的数据共享和通信需要通过进程间通信(IPC)机制来实现。

  3. 协程(Coroutine):协程是一种用户级的轻量级线程,由编程语言或库提供支持。协程可以在单个线程内部实现并发,通过协程的切换来实现任务的交替执行。

  4. 同步(Synchronization):同步是一种机制,用于协调多个线程或进程的执行顺序和访问共享资源的顺序。常见的同步机制包括互斥锁、条件变量、信号量和屏障等。

  5. 互斥(Mutex):互斥是一种同步机制,用于保护共享资源的访问。只有一个线程或进程可以持有互斥锁,其他线程或进程必须等待互斥锁释放后才能访问共享资源。

共享资源和竞态条件是并发编程中重要的概念:

  • 共享资源是多个线程或进程需要访问和共享的数据、变量或资源。多个线程或进程可以同时读取共享资源,但在至少一个线程或进程进行写操作时,需要确保其他线程或进程不会同时进行读或写操作。

  • 竞态条件(Race Condition)是指多个线程或进程同时访问共享资源,并且最终的结果依赖于执行的顺序和时间。当多个线程或进程对共享资源进行读写操作时,如果没有合适的同步机制保护,可能导致意外的结果或错误。

为了避免竞态条件,需要使用适当的同步机制,如互斥锁、条件变量或原子操作等。通过对共享资源进行合理的同步和互斥,可以保证线程或进程之间的正确交互,避免数据的不一致性和意外的结果。

同时,需要注意在并发编程中正确地处理同步和互斥的逻辑,避免死锁(Deadlock)和活锁(Livelock)等问题,以确保程序的正确性和性能。

image.png

Go语言并发编程

两个关键概念是协程(Goroutine)和通道(Channel):

  1. 协程(Goroutine):

    • 协程是轻量级的并发执行单元,由Go语言运行时(Goroutine Scheduler)进行调度。
    • 通过关键字go可以创建一个新的协程,并在函数调用前加上go关键字即可。
    • 协程之间的切换开销非常小,可以高效地创建大量的协程。
    • 协程之间通过共享内存来进行通信,但需要借助同步原语来保证数据访问的安全性。
  2. 通道(Channel):

    • 通道是用于协程之间通信和同步的机制,可以在不同协程之间传递数据。
    • 通道可以是带有特定类型的数据传输管道,通过make函数创建。
    • 通过通道的发送和接收操作实现协程之间的数据传递,发送和接收操作会阻塞协程的执行,直到对应的接收或发送操作可以进行。

Go语言还提供了一些并发原语和常用技术来辅助并发编程:

  1. WaitGroup:

    • WaitGroup用于等待一组协程的完成,可以阻塞主协程,直到指定数量的协程执行完毕。
    • 通过Add方法增加等待的协程数量,通过Done方法减少协程数量,通过Wait方法进行阻塞等待。
  2. Mutex:

    • Mutex是一种互斥锁,用于保护共享资源的访问,避免竞态条件。
    • 通过Lock方法获取锁,通过Unlock方法释放锁,一次只能有一个协程持有该锁。
  3. Atomic:

    • Atomic提供了原子操作的支持,用于对共享变量进行原子读写和更新操作。
    • 原子操作是不可中断的单一操作,可以在没有锁的情况下进行并发安全的操作。

除了上述原语和技术之外,Go语言还提供了其他并发相关的工具和库,如Once、RWMutex、Ticker、Timer等,可以根据具体需求选择合适的工具来实现并发编程。

通过使用协程和通道以及其他并发原语和技术,Go语言使得并发编程更加简洁和高效。开发者可以通过并发编程在Go语言中轻松处理并发任务,并保证数据访问的安全性和协程间的通信与同步。

代码详细学习

启动单个goroutine

func hello() {
   fmt.Println("hello goroutine")
}

func TestGoRoutine4(t *testing.T) {
   go hello()
   fmt.Println("main goroutine done!")
   time.Sleep(time.Second)
}

运行结果 image.png

启动多个goroutine

var wg sync.WaitGroup // 等待组

func sayHello(i int) {
   defer wg.Done() // goroutine执行完毕后,将等待组中的数量减1
   fmt.Println("hello goroutine", i)
}

func TestGoRoutine5(t *testing.T) {
   for i := 0; i < 10; i++ {
      wg.Add(1) // 添加一个goroutine, 等待组中的数量加1
      go sayHello(i)
   }
   wg.Wait() // 等待所有goroutine执行完毕
   fmt.Println("main goroutine done!")
}

sync.WaitGroup类型在sync包中定义,提供了一些用于等待一组协程完成的方法。下面是WaitGroup的详细 API 说明:

  1. func (wg *WaitGroup) Add(delta int)

    • 功能:增加等待的协程数量
    • 参数:delta,要增加的数量,可以为负数
    • 说明:每个Add()调用会增加等待的协程数量,通常在启动协程之前调用
  2. func (wg *WaitGroup) Done()

    • 功能:标记一个协程完成
    • 说明:每个协程完成时,需要调用Done()方法进行标记,相当于减少等待的协程数量
  3. func (wg *WaitGroup) Wait()

    • 功能:阻塞等待所有协程完成
    • 说明:调用Wait()方法会阻塞当前协程,直到等待的协程数量变为零,即所有协程都完成

WaitGroup通过上述方法提供了对等待协程的操作,可以使用它们来实现对一组协程的同步等待。

一般的使用流程是:

  1. 在主协程中创建一个WaitGroup实例。
  2. 在启动协程之前,使用Add()方法增加等待的协程数量。
  3. 在每个协程的最后,使用Done()方法标记协程完成。
  4. 在主协程中调用Wait()方法,阻塞等待所有协程完成。

通过WaitGroup,可以轻松实现等待一组协程完成的逻辑,以确保在协程完成后再执行后续的操作。

channel

协程之间的通信是并发编程中非常重要的一部分,Go语言通过通道(Channel)提供了一种方便、安全的协程间通信机制。

通道是一种类型化的数据结构,可以在协程之间传递数据。通过通道,协程可以发送和接收数据,实现了数据的同步和共享。

下面是使用通道进行协程间通信的基本操作:

  1. 创建通道:
make(chan 元素类型, [缓冲大小])
  1. 发送数据到通道:

    ch <- data
    

    data是要发送到通道的数据。

  2. 从通道接收数据:

    data := <-ch
    

    将通道中的数据接收到data变量中。

  3. 关闭通道:

    close(ch)
    

    通道可以被显式关闭,以表示没有更多的数据将被发送到通道中。关闭通道后,仍然可以从通道接收剩余的数据。

协程通过发送和接收操作来实现通信,这些操作会在必要时阻塞协程的执行,直到对应的接收或发送操作可以进行。这种阻塞和同步的机制可以有效地协调协程之间的执行。

// 协程之间的通信 : 通过channel,channel是goroutine之间的通信方式
func TestGoRoutine2(t *testing.T) {
   ch := make(chan int)
   for i := 0; i < 5; i++ {
      go func(j int) {
         ch <- j
      }(i)
   }
   for i := 0; i < 5; i++ {
      fmt.Println(<-ch)
   }
}

// 解决同步问题
func TestGoRoutine3(t *testing.T) {
   ch := make(chan int)
   for i := 0; i < 5; i++ {
      go func(j int) {
         ch <- j
      }(i)
   }
   for i := 0; i < 5; i++ {
      fmt.Println(<-ch) // 一直阻塞,直到有数据
   }
}

func TestCalSquare(t *testing.T) {
   src := make(chan int)
   dest := make(chan int, 3)
   go func() {
      // 关闭channel,关闭后不能再写入数据,但是可以读取数据,在for range中读取数据时,当channel中没有数据时,会自动退出循环
      defer close(src)
      for i := 0; i < 10; i++ {
         src <- i
      }
   }()
   go func() {
      defer close(dest)
      for i := range src {
         dest <- i * i
      }
   }()
   for i := range dest {
      fmt.Println(i)
   }
}