go 精髓

以通信来共享内存

将资源读进内存-->共享内存,一个个进程/线程进行处理,这是常见模式。 go channel 是一种直接在进程/线程之间传递资源的方式,即以通信来共享内存。 这便是go的精髓。

扩展-一些名词了解


Linux、IPC(进程间通信)、进程(几个状态)、线程、同步、异步、信号、管道、socket、消息队列、字节流、结构化消息、通信、信号量、共享内存、内核空间、用户空间、PID、PPID、fork、COW

将进程细化为多个状态后,系统CPU就可以更加灵活地调度,进程下还有更细的线程,有N多状态,

GO调度器实现一套机制,统一调度与分配这些CPU、进程、线程等资源,分为M(内核)、P(go上下文)、G(goroutine)三层

以上概念不学linux原理的话,听个名词就好,GO中重点学习两个点

1. 第三层G就是我们经常使用的goroutine协程,掌握goroutine的使用

2. GO支持的IPC方法有 信号、管道、socket,掌握这三种的实现方式

掌握goroutine的使用

channel 定义

chan T 双向 chan<-T 只发送,即只往通道中发送 <- chan T 只接收 通道类型,也是引用类型,零值为nil ``` package main import ( "fmt" ) func main(){ var c3 chan int res := c3 ==nil //chan default:<nil>,eq nil:true fmt.Printf("chan default:%v,eq nil:%v\n",c3,res) } ```

for 与 channel

可以从未初始化的通道(nil)中取值,但会被阻塞 当通道关闭时,会将通道中的元素全部取出后,语句再结束 必须是一个双向通道或接收通道,不是只是发送通道。 var c3 chan int for elem := range c3 { fmt.Println(elem) }

go channel特性

 
同一时刻,仅有一个goroutine可以向该通道发送元素值,同时也仅有一个goroutine可以从该通道接收元素值,即通道是串行的。

通道中的元素值,严格按发送到该通道的先后顺序排列,最先发送到的元素值,一定最先被接收。等效于先进先出的消息队列。

通道中的元素值,具有原子性,不可被分割;每个元素值只能被一个goroutine接收,被接收后,立刻从通道中清除。

goroutine的执行与主程序是并行的,主程序结束时,还没有执行完毕的goroutine会被强制中止。

通道的传值是复制,不是引用。

通道初始化

 
通道未初始化时其值为nil,可以从nil中尝试接收元素,但会被永远阻塞

make(chan int)
初始化一个可以接收、发送int值类型的通道,无缓冲

make(chan int, 10)
初始化一个可以接收、发送int值类型的通道,可缓冲10个int值。
第11个int值向通道中发送时会被阻塞。
被缓冲的元素值,会严格按发送的顺序接收。

从通道中接收元素

c := make(chan int, 10) n := <- c 如果通道 c 被关闭,那么n的值为该元素类型的零值, 本例int类型的零值为0; 如果是在接收的过程中被关闭了,n的值同样为0。 ``` package main import "fmt" func main() { c:= make(chan int,10) close(c) n:=<-c fmt.Println(n) } ``` 输出: 0 这表示从一个关闭的通道中取值,取到的是这个通道类型的0值 n,ok := <- c 这种写法与上面的唯一区别在于,当通道关闭时,ok的值为false ``` package main import "fmt" func main() { c:= make(chan int,10) close(c) n,ok:=<-c fmt.Println(n,ok) // 0 false if n,ok:=<-c; ok { fmt.Println(n) }else{ fmt.Println("通道已关闭") } } ``` 输出: 0 false 通道已关闭 如果是for读取已关闭的通道,则会直接跳过 ``` package main import "fmt" func main() { c:= make(chan int,10) close(c) n,ok:=<-c fmt.Println(n,ok) // 0 false if n,ok:=<-c; ok { fmt.Println(n) }else{ fmt.Println("通道已关闭") } fmt.Println("----------------for start ----------") for n:= range c { fmt.Println(n) } fmt.Println("----------------for over ----------") } ``` 输出: 0 false 通道已关闭 ----------------for start ---------- ----------------for over ----------

关闭通道

通常在发送端关闭通道 不可重复关闭通道,关闭一个已经关闭的或未初始化的通道会引发异常 通道关闭后,其中未接收的数据仍可被接收 接收端应先判断通道是否关闭再从中取值,否则若通道关闭可能取出的值是通道类型的零值 ``` func test11(){ dataChan := make(chan int,3) startChan := make(chan string,1) overChan := make(chan string,2) go func() { <- startChan fmt.Println("start receive data ") for{ if elem,ok:= <- dataChan; ok{ fmt.Printf("%v\n",elem) }else { break } } fmt.Println("reciver over") overChan <- "rec over" }() go func() { for i:=0;i<3;i++ { dataChan <- i fmt.Sprintf("send data:%v\n",i) } fmt.Println("send data over") //在接收之前关闭通道 close(dataChan) fmt.Println("dataChan closed") //开始接收 startChan <- "begin" overChan <- "send data over" }() <- overChan <- overChan fmt.Println("main over") } ``` 执行这段代码输出: send data over dataChan closed start receive data 0 1 2 reciver over main over

长度与容量

长度是通道中元素的个数,非固定值 容量是通道中可能缓存的元素个数最大个数 package main import "fmt" func main() { c1 := make(chan int,5) fmt.Printf("c1 长度:%v, 容量:%v\n",len(c1),cap(c1)) c2 := make(chan int) fmt.Printf("c2 长度:%v, 容量:%v\n",len(c2),cap(c2)) } c1 长度:0, 容量:5 c2 长度:0, 容量:0

单向通道

chan T 双向 用于channel定义 chan<-T 只发送 用于接口、函数参数定义 <- chan T 只接收 用于接口、函数参数定义 双向通道可以转换为发送/接收通道,反之不可以。
channel示例及同步

channel简单示例

``` package main import ( "fmt" "time" ) //channel的创建,发送,接收 func channe1(){ //创建,channel是有类型的 c1 := make(chan int) //接收,在这段程序中接收方必须是一个goroutine,因为只在主程序中发送而不接收,程序会报deadlock //通常使用匿名函数开一个与主程序同时执行的内部方法,即并行执行 go func(){ fmt.Println("接收数据准备") //这里接收channel使用了io输出功能,io是可以被抢占控制权的,即IO的特性 fmt.Println(<- c1) fmt.Println("接收数据完成") //关闭,不显式关闭时,channel会随主程序(即main)的运行结束而结束 //如果“接收”处理数据的时间较长,就会出现主程序已经结束,但接收方还没处理完的情况 //此时可以让主程序sleep一段时间,等待接收方把数据处理完毕再关闭 close(c1) fmt.Println("接收结束") }() //发送数据,“接收”程序要在发送之前准备, //意思就是发送数据之前,要先为channel准备好接收; //否则,执行<- 1将1发送到channel时,go发现没有人接收,会报deadlock c1 <- 1 //接收方与主程序同时执行 //主程序在此停止1毫秒,就相当于主程序等了接收方一毫秒 time.Sleep(time.Millisecond) } func main(){ channe1() fmt.Println("主程序结束") } ```

channel同步

如果一个动作会触发另外一个动作,那么这个行为通常被称为事件(event); 如果这个事件不附带信息,那么此类事件又通常被用于同步。 channel有发送、接收、关闭三个操作; 发送触发接收,如果一个channel不发送,那么接收将处于阻塞。这种同步,可用于消息通知。 ``` package main import ( "fmt" "time" ) func test(){ c := make(chan struct{}) go func(){ fmt.Println("我要花两分钟去看看园子里的花还活着吗") time.Sleep(7*time.Second) c <- struct{}{} }() //程序会在这里等待7秒 <- c //然后打印出下面这句话 fmt.Println("这花从屋里移值出去后,活得比以前更好了") } func main(){ test() } ```
sync.WaitGroup

让后台协程有序执行

``` package main import ( "gitee.com/tanpf/tools" "sync" "time" ) func startTask() { cmdList := make([]string,6) cmdList[0] = "mkdir -p /tmp/test/dir1" cmdList[1] = "mkdir /tmp/test/dir1/dir2" cmdList[2] = "mkdir /tmp/test/dir1/dir2/dir3" cmdList[3] = "mkdir /tmp/test/dir1/dir2/dir3/dir4" cmdList[4] = "mkdir /tmp/test/dir1/dir2/dir3/dir4/dir5" cmdList[5] = "mkdir /tmp/test/dir1/dir2/dir3/dir4/dir5/dir6" var cmdLength = len(cmdList) var cmdChan = make(chan string, cmdLength) var onOff = make(chan int, cmdLength) var wg sync.WaitGroup wg.Add(cmdLength) for i,cmd := range cmdList { cmdChan <- cmd go task(&cmdChan, &onOff, &wg, i, cmdLength) } //启动第一个任务,第一个任务结束时会启动第二个任务,依次类推 onOff <- 0 wg.Wait() } func task(cmdChan *chan string, onOff *chan int, wg *sync.WaitGroup, seq, maxLength int) { // for { // if i,ok := <- *onOff; ok { // if i == seq { // //跳出循环等待,开始当前顺序命令执行 // break // }else { // //将取出的命令执行序号放回通道,然后暂停一段时间该后台协程 // *onOff <- i // //让不该执行的后台协程等待,就会让该执行的后台线程更容易获取执行机会,因为它不用等 // time.Sleep(10*time.Microsecond) // } // } // } // 下面的代码与上面的基本等价,初学还是都体会一下 // 当通道无数据时,for会等待数据的到来,换句话讲,channel会阻塞for循环 // onOff可缓冲6个数据,但同一时间只放一个, // 也就是说,同一时刻最多有一个后台协程获取执行权 for i := range *onOff { if i == seq { //跳出循环等待,开始当前顺序命令执行 break }else { //将取出的命令执行序号放回通道,然后暂停一段时间该后台协程 *onOff <- i //让不该执行的后台协程等待,就会让该执行的后台线程更容易获取执行机会,因为它不用等 time.Sleep(10*time.Microsecond) } } //从缓冲中取出的元素顺序,严格遵从放入时的顺序 //所以取出的顺序一定会按命令列表的下标从0排列到列表结束 if cmd,ok := <- *cmdChan; ok{ tools.ExecShell(cmd) } wg.Done() seq++ if seq < maxLength{ *onOff <- seq } } func main(){ startTask() } ``` 代码能否运行起来不重要,先来体会以下几个内容: 1. 思考一下这段代码是如何体现前面说的五个特性的, 2. 后台协和与main并行执行,main并不会等, 这段代码是使用了waitGroup,直译就是“等待组”,意思也挺贴切 3. 代码中将要执行的命令放到了channel中,这就是用通信来共享内存 4. 最核心的是开关onOff通道,是一个典型的 在各个后台协程中 以通道共享数据 来进行并发控制 的例子 5. 不该自己执行时暂停10毫秒的设计,如果没有这个,可能会出现某几个后台协程之间疯狂在通道中传值的情况 linux mkdir有个特性,其父目录若不存在,则无法创建子目录; 若命令不按列表顺序执行,那么最后的目录肯定不会创建成功, 若成功创建所有目录,则表明命令是按顺序执行的。 若去掉onOff顺序控制,本例就是一个后台并发执行的例子。

channel数组

 
package main
import (
    "fmt"
    "sync"
)
    
type worker struct {
    in chan int
    done func()
}
    
//“接收”方程序
func (wk *worker) doWork(id int){
    //接收的确是按数组顺序顺序打印出来的,但这只是程序第一次运行的情况
    //接收是在发送之前就以并行的方式运行起来了,之后数据中每个channel都一直处于阻塞等待状态
    //也就是说数组中的每个channel谁先打印出数据,就表示该谁先发送数据(忽略channel传送数据时长差异)
    for n := range wk.in {
    fmt.Printf("第 %d 次接收的信息为 %c\n",id,n)
    //通知主程序工作处理完毕
    wk.done()
    }
}
    
func createWorker(id int, wtg *sync.WaitGroup) worker{
    //channel作为struct的一个属性
    wk := worker{
        //chan<-表示channel只用于发送数据,即输入
        in : make(chan int),
        done: func(){
            wtg.Done()
        },
    }
    //channel创建之后,就开始以并行的方式建立“接收”方
    go wk.doWork(id)
    return wk
}
    
func channel2(){
    //WaitGroup的wait(在主程序中调用)与done(在与主程序并行执行的“接收”方中调用)的交互,
    //可以达到等待所有channel运行完毕,再让主程序运行的效果
    //而不是程序员猜想channel“接收”需要多少时间运行,
    //然后去主程序中设置time.Sleep让主程序等待
    var wtg sync.WaitGroup
    //channel数组
    var workers [8]worker
    for i := 0; i < 8; i++{
        //使用引用的方式传送参数,所有的channel公用一个WaitGroup
        workers[i] = createWorker(i,&wtg)
    }
    //要一次性添加完要等待执行的channel个数
    wtg.Add(16)
    for i,worker := range workers {
        worker.in <- 'a' + i
       //wtg.Add(1) //这种方式会报错
    }
    for i,worker := range workers {
        worker.in <- 'A' + i
    }
    //等待所有channel执行完毕,否则一直阻塞
    wtg.Wait()
    fmt.Println("所有channel执行完毕")
}
    
func main(){
    channel2()
    fmt.Println("主程序结束")
}
    
第 0 次接收的信息为 a
第 1 次接收的信息为 b
第 2 次接收的信息为 c
第 2 次接收的信息为 C
第 3 次接收的信息为 d
第 3 次接收的信息为 D
第 4 次接收的信息为 e
第 4 次接收的信息为 E
第 0 次接收的信息为 A
第 1 次接收的信息为 B
第 6 次接收的信息为 g
第 5 次接收的信息为 f
第 5 次接收的信息为 F
第 6 次接收的信息为 G
第 7 次接收的信息为 h
第 7 次接收的信息为 H
所有channel执行完毕
主程序结束

无缓冲通道的阻塞与同步
**结论:是的。** 无缓冲通道(`make(chan int)`)的发送与接收是**同步的**(Handshake / Rendezvous,握手)。 发送方必须等到有接收方准备好,才能成功将数据"交接"出去;否则发送方会被阻塞。 具体分两种情况: 1. **发送方先执行**:发送数据时若没有另一个 Goroutine 在等待接收,发送方会**被阻塞**,直到有接收方准备好。 2. **接收方先执行**:接收数据时若没有另一个 Goroutine 在等待发送,接收方会**被阻塞**,直到有发送方准备好。 **两者必须同时就绪,才能完成一次"握手"**,数据才会从发送方直接拷贝给接收方(不经过中间缓冲区)。
下面这段代码会引发**死锁(fatal error)**,因为发送方没有接收方: ```go func main() { ch := make(chan int) ch <- 1 // 这里会永远阻塞,main goroutine 只有发送,没有接收 } ```
必须让接收方在另一个 Goroutine 中准备好(或由另一个 Goroutine 发送,主 Goroutine 接收): ```go func main() { ch := make(chan int) // 接收方先准备好(在另一个协程中) go func() { <- ch // 阻塞等待接收 fmt.Println("received") }() ch <- 1 // 此时发送方发现已有接收方在等待,立即发送成功 time.Sleep(time.Second) // 等待协程打印 } ```
**对比有缓冲通道** `make(chan int, 10)`: - 缓冲区未满时,发送不需要接收方准备好 - 缓冲区非空时,接收不需要发送方准备好 - 只有缓冲区满 / 空时才会阻塞 **总结**:无缓冲通道强调"同步",**发送成功的前提是必须有接收方正在等待**(反之亦然)。 因此无缓冲通道天然适合用作 Goroutine 之间的**信号传递**或**同步屏障**。
context.Context 用法详解
以 `gowks/pkg/drs/c2p.go` 第 48 行(DescribeTable 函数首参)为例: ```go func DescribeTable(ctx context.Context, db *sql.DB, table string) ([]ColumnDef, error) { rows, err := db.QueryContext(ctx, "DESCRIBE "+qt) ... } ``` `context.Context` 是 Go 标准库接口,用于**跨 API 边界、跨 goroutine** 传递三样东西: 1. **取消信号**(cancel):外部一声令下,整条调用链同时收工 2. **截止时间 / 超时**(deadline / timeout):到点自动取消 3. **请求级元数据**(value):如 trace id(谨慎使用) 接口定义只有四个方法: ```go type Context interface { Deadline() (deadline time.Time, ok bool) // 截止时间 Done() <-chan struct{} // 取消/超时时被关闭的通道(只读) Err() error // 取消原因:Canceled 或 DeadlineExceeded Value(key any) any // 请求级元数据 } ``` **创建与派生**(ctx 是一棵树,根在上、枝叶向下): | 函数 | 作用 | |------|------| | `context.Background()` | 根 ctx,永不取消(程序入口用) | | `context.TODO()` | 占位根 ctx(还不知道该传什么时) | | `context.WithCancel(parent)` | 派生可手动取消的 ctx,返回 `cancel()` | | `context.WithTimeout(parent, d)` | 派生超时自动取消的 ctx | | `context.WithDeadline(parent, t)` | 派生到点自动取消的 ctx | | `context.WithValue(parent, k, v)` | 派生携带元数据的 ctx | **约定俗成**:ctx 作为函数**第一个参数**,命名 `ctx`;显式逐层传递,不存全局变量。
c2p.go 中 ctx 贯穿整条调用链,共四处典型用法: **1. 透传给 database/sql(c2p.go:53)** ```go rows, err := db.QueryContext(ctx, "DESCRIBE "+qt) ``` `QueryContext` 把 ctx 交给数据库驱动:ctx 一旦取消,**在途 SQL 立即中断、连接被释放**;若用不带 ctx 的 `db.Query`,卡住的查询无法中止。 **2. 派生超时 ctx(c2p.go:671,schemaFor 缓存表结构)** ```go qctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() defs, err := DescribeTable(qctx, l.db, table) ``` 父 ctx 不取消、子查询最多等 30 秒——**超时只收窄不放宽**。 **3. 顶层入口一路透传(转换阶段)** ``` ConvertCSVToParquet(ctx, ...) // 入口 → convertSources(ctx, ...) // worker 池 → convertOneSource(ctx, ...) // 单文件转换 ``` **4. 内存直通导入(c2p.go:693/796)** ```go func (l *C2PMemLoader) Run(ctx context.Context, task Task, ...) (res Result) { ... lr, err := l.imp.streamLoadPayload(ctx, streamJob{...}, progress) } ``` HTTP 的 Stream Load 请求也挂在**同一棵取消树**上——Ctrl+C 一个信号,SQL、HTTP、文件转换全部收工。
ctx 是**广播式取消**:一个 `cancel()`,所有从它派生的 ctx 的 `Done()` 通道**同时关闭**——天然适合 worker 池"一键停止"。c2p.go 的 convertSources(14 协程并发转换 315GB CSV)就是标准范本,三处配合: **1. 派发协程:select 双路,取消即停止派活(c2p.go:453)** ```go go func() { defer close(jobs) for i, sec := range secs { select { case jobs <- job{sec: sec, tag: tags[i]}: // 派活 case <-ctx.Done(): // 取消:立即停派 return } } }() ``` **2. worker 接活前先看取消(c2p.go:428)** ```go for j := range jobs { if ctx.Err() != nil { // 已被取消:不再接新活 return } chunks, rows, bytes, err := convertOneSource(ctx, j.sec, j.tag, defs, opt, wopt, sep) ... } ``` **3. 单文件长循环每块边界检查(c2p.go:506)** ```go for { if ctx.Err() != nil { return nil, 0, 0, ctx.Err() // 300 万行一块的边界上尽早退出 } ... } ``` **要点总结**: - 取消后,派发方不再喂活、worker 不再接活、在途的长任务在**块边界**尽快退出——三层都拦,取消延迟最小 - `ctx.Err()` 轮询(非阻塞)与 `select <-ctx.Done()`(等待)是两种消费姿势,按场景选 - 最后 `wg.Wait()` 等所有 worker 收尾,再统一上报 `ctx.Err()` **本质**:`Done()` 返回的就是一个 `<-chan struct{}`,取消即 `close`——channel 一关,所有接收方同时被唤醒。这正是"以通信来共享内存",与前面 channel 同步一节完全呼应:**context 是建立在 channel 之上的广播机制**。
**1. 派生必须 `defer cancel()`** ```go qctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() // 即使提前返回也要 cancel,否则父 ctx 资源泄漏到超时为止 ``` `WithTimeout` / `WithCancel` 返回的 `cancel` 不调,子 ctx 会一直挂在父树上,直到超时到期才释放。 **2. 非阻塞检查 vs 阻塞等待** ```go if ctx.Err() != nil { return ctx.Err() } // 轮询:长循环边界用 select { // 等待:和 channel 收发竞争时用 case jobs <- j: case <-ctx.Done(): return } ``` **3. 根 ctx 只在程序入口创建** `context.Background()` / `TODO()` 只出现在 main 或请求入口,之后**逐层透传**,底层函数不自己造根(造了就切断了取消链)。 **4. 不要把 ctx 存进 struct 长期持有** c2p.go 的 `C2PMemLoader` struct 里只有 cfg/db/emit,**没有存 ctx**——ctx 跟随每次 `Run(ctx, task)` 调用传入,一个任务一条生命周期,互不串扰。 **5. `ctx.Value` 不是传参工具** 多并发场景中往 Value 里塞业务数据是反模式——类型不安全、无编译期检查。**参数能显式传的,就别塞 ctx**;Value 只放真正的请求级元数据(trace id 等)。 **6. 取消是协作式的,不是强杀** `cancel()` 不会杀掉 goroutine,只是把 Done() 通道关闭。被调用方必须**主动检查**(Err 轮询或 select Done)并自愿退出——c2p.go 三层检查就是这个协作关系的落地。
参考