channel 實戰應用,這篇就夠了!

Remember發表於2021-01-22

有一說一,這篇文章有點標題黨了,但是絕對是乾貨。

已經有很多關於 channel 的文章,為什麼我還要寫呢?任何知識點,只要你想,就可以從不同的角度切入!那就寫點 channel 應用相關的東西。通過不同場景使用 channel 特性加深理解!所以在看這篇文章之前,首先得先去了解 channel。

由 channel 引發的血案

上面那篇文章漏了一個我覺得很關鍵的知識點,並且我們還經常在上面犯錯誤。即使是那些牛逼的開源專案,也有過類似 bug。

我的問題是:channel 的哪些操作會引發 panic?

1.關閉一個 nil 值 channel 會引發 panic。
package main

func main() {
  var ch chan struct{}
  close(ch)
}

圖片

2.關閉一個已關閉的 channel 會引發 panic。

package main

func main() {
  ch := make(chan struct{})
  close(ch)
  close(ch)
}

圖片

3.向一個已關閉的 channel 傳送資料。

package main

func main() {
  ch := make(chan struct{})
  close(ch)
  ch <- struct{}{}
}

圖片

以上三種 channel 操作會引發 panic。

你可能會說,我咋麼會犯這麼愚蠢的錯誤。這只是一個很簡單的例子,實際專案是很複雜的,一不小心,你就會忘了自己曾在哪一個 g 裡關閉過 channel。

如果你對某塊程式碼沒有安全感,相信我,就算它中午不出事,早晚也得出事。

channel 的一些應用

  • 訊號通知
  • 超時控制
  • 生產消費模型
  • 資料傳遞
  • 控制併發數
  • 互斥鎖
  • one million……

1.訊號通知

經常會有這樣的場景,當資訊收集完成,通知下游開始計算資料。

package main

import (
  "fmt"
  "time"
)

func main() {
  isOver := make(chan struct{})
  go func() {
    collectMsg(isOver)
  }()
  <-isOver
  calculateMsg()
}

// 採集
func collectMsg(isOver chan struct{}) {
  time.Sleep(500 * time.Millisecond)
  fmt.Println("完成採集工具")
  isOver <- struct{}{}
}

// 計算
func calculateMsg() {
  fmt.Println("開始進行資料分析")
}

如果只是單純的使用通知操作,那麼型別就使用 struct{}。因為空結構體在 go 中是不佔用記憶體空間的,不信你看。

package main

import (
"fmt"
"unsafe"
)

func main() {
  res := struct{}{}
  fmt.Println("佔用空間:", unsafe.Sizeof(res))
}
//佔用空間: 0

2.執行任務超時

我們在做任務處理的時候,並不能保證任務的處理時間,通常會加上一些超時控制做異常的處理。

package main

import (
  "fmt"
  "time"
)

func main() {
  select {
  case <-doWork():
    fmt.Println("任務結束")
  case <-time.After(1 * time.Second):
    fmt.Println("任務處理超時")
  }
}

func doWork() <-chan struct{} {
  ch := make(chan struct{})
  go func() {
    // 任務處理耗時
    time.Sleep(2 * time.Second)
  }()
  return ch
}

3.生產消費模型

生產者只需要關注生產,而不用去理會消費者的消費行為,更不用關心消費者是否執行完畢。而消費者只關心消費任務,而不需要關注如何生產。

package main

import (
  "fmt"
  "time"
)

func main() {
  ch := make(chan int, 10)
  go consumer(ch)
  go producer(ch)
  time.Sleep(3 * time.Second)
}

// 一個生產者
func producer(ch chan int) {
  for i := 0; i < 10; i++ {
    ch <- i
  }
  close(ch)
}

// 消費者
func consumer(task <-chan int) {
  for i := 0; i < 5; i++ {
    // 5個消費者
    go func(id int) {
      for {
        item, ok := <-task
        // 如果等於false 說明通道已關閉
        if !ok {
          return
        }
        fmt.Printf("消費者:%d,消費了:%d\n", id, item)
        // 給別人一點機會不會吃虧
        time.Sleep(50 * time.Millisecond)
      }
    }(i)
  }
}

4.資料傳遞

極客上一道有意思的題,假設有4個 goroutine,編號為1,2,3,4。每秒鐘會有一個 goroutine 列印出它自己的編號。現在讓你寫一個程式,要求輸出的編號總是按照1,2,3,4這樣的順序列印。類似下圖,

圖片

package main

import (
  "fmt"
  "time"
)

type token struct{}

func main() {
  num := 4
  var chs []chan token
  // 4 個work
  for i := 0; i < num; i++ {
    chs = append(chs, make(chan token))
  }
  for j := 0; j < num; j++ {
    go worker(j, chs[j], chs[(j+1)%num])
  }
  // 先把令牌交給第一個
  chs[0] <- struct{}{}
  select {}
}

func worker(id int, ch chan token, next chan token) {
  for {
    // 對應work 取得令牌
    token := <-ch
    fmt.Println(id + 1)
    time.Sleep(1 * time.Second)
    // 傳遞給下一個
    next <- token
  }
}

5.控制併發數

我經常會寫一些指令碼,在凌晨的時候對內或者對外拉取資料,但是如果不對併發請求加以控制,往往會導致 groutine 氾濫,進而打滿 CPU 資源。往往不能控制的東西意味著不好的事情將要發生。對於我們來說,可以通過 channel 來控制併發數。

package main

import (
  "fmt"
  "time"
)

func main() {
  limit := make(chan struct{}, 10)
  jobCount := 100
  for i := 0; i < jobCount; i++ {
    go func(index int) {
      limit <- struct{}{}
      job(index)
      <-limit
    }(i)
  }
  time.Sleep(20 * time.Second)
}

func job(index int) {
  // 耗時任務
  time.Sleep(1 * time.Second)
  fmt.Printf("任務:%d已完成\n", index)
}

當然了,sync.waitGroup 也可以。

package main

import (
  "fmt"
  "sync"
  "time"
)

func main() {
  var wg sync.WaitGroup
  jobCount := 100
  limit := 10
  for i := 0; i <= jobCount; i += limit {
    for j := 0; j < i; j++ {
      wg.Add(1)
      go func(item int) {
        defer wg.Done()
        job(item)
      }(j)
    }
    wg.Wait()
  }
}

func job(index int) {
  // 耗時任務
  time.Sleep(1 * time.Second)
  fmt.Printf("任務:%d已完成\n", index)
}

6.互斥鎖

我們也可以通過 channel 實現一個小小的互斥鎖。通過設定一個緩衝區為1的通道,如果成功地往通道傳送資料,說明拿到鎖,否則鎖被別人拿了,等待他人解鎖。

package main

import (
  "fmt"
  "time"
)

type ticket struct{}

type Mutex struct {
  ch chan ticket
}

// 建立一個緩衝區為1的通道作
func newMutex() *Mutex {
  return &Mutex{ch: make(chan ticket, 1)}
}

// 誰能往緩衝區為1的通道放入資料,誰就獲取了鎖
func (m *Mutex) Lock() {
  m.ch <- struct{}{}
}

// 解鎖就把資料取出
func (m *Mutex) unLock() {
  select {
  case <-m.ch:
  default:
    panic("已經解鎖了")
  }
}

func main() {
  mutex := newMutex()
  go func() {
    // 如果是1先拿到鎖,那麼2就要等1秒才能拿到鎖
    mutex.Lock()
    fmt.Println("任務1拿到鎖了")
    time.Sleep(1 * time.Second)
    mutex.unLock()
  }()
  go func() {
    mutex.Lock()
    // 如果是2拿先到鎖,那麼1就要等2秒才能拿到鎖
    fmt.Println("任務2拿到鎖了")
    time.Sleep(2 * time.Second)
    mutex.unLock()
  }()
  time.Sleep(500 * time.Millisecond)
  // 用了一點小手段這裡最後才能拿到鎖
  mutex.Lock()
  mutex.unLock()
  close(mutex.ch)
}

到這裡,這篇文章已經尾聲了。當然我只是列舉了部分 channel 的應用場景。你完全可以發揮自己的想象,在實際工作中,構建更完美且貼近生產的設計。

如果你還有其他不同的應用模式場景,歡迎下方留言和我交流。

另外原始碼我放在 github 上了,地址:github.com/wuqinqiang/Go_Concurren...

如果文章對你有所幫助,點贊、轉發、留言都是一種支援!
圖片

本作品採用《CC 協議》,轉載必須註明作者和本文連結
吳親庫裡

相關文章