文章目录
- 前置篇
- 显式
- 组合
- 并发
- 入门篇
- Go 包的初始化次序
- 初始化一个项目
- 核心篇
- Channel
- 不带缓冲的channel
- 带缓冲的channel
- select
- 同步原语-锁
- 同步原语-条件变量
- 原子操作
- 泛型
- 实战篇
- 参考资料
前置篇
显式
在 C 语言中,下面这段代码可以正常编译并输出正确结果:
#include <stdio.h>
int main() {
short int a = 5;
int b = 8;
long c = 0;
c = a + b;
printf("%ld\n", c);
}
我们看到在上面这段代码中,变量 a、b 和 c 的类型均不相同,C 语言编译器在编译c =
a + b这一行时,会自动将短整型变量 a 和整型变量 b,先转换为 long 类型然后相加,
并将所得结果存储在 long 类型变量 c 中。那如果换成 Go 来实现这个计算会怎么样呢?我
们先把上面的 C 程序转化成等价的 Go 代码:
package main
import "fmt"
func main() {
var a int16 = 5
var b int = 8
var c int64
c = a + b
fmt.Printf("%d\n", c)
}
如果我们编译这段程序,将得到类似这样的编译器错误:“invalid operation: a + b
(mismatched types int16 and int)”。我们能看到 Go 与 C 语言的隐式自动类型转换不
同,Go 不允许不同类型的整型变量进行混合计算,它同样也不会对其进行隐式的自动转
换。
因此,如果要使这段代码通过编译,我们就需要对变量 a 和 b 进行显式转型,就像下面代
码段中这样:
c = int64(a) + int64(b)
fmt.Printf("%d\n", c)
在 Go 语言中,不同类型变量是不能在一起进行混合计算的,这是因为 Go 希望开发人员
明确知道自己在做什么,这与 C 语言的“信任程序员”原则完全不同,因此你需要以显式
的方式通过转型统一参与计算各个变量的类型。
除此之外,Go 设计者所崇尚的显式哲学还直接决定了 Go 语言错误处理的形态:Go 语言
采用了显式的基于值比较的错误处理方案,函数 / 方法中的错误都会通过 return 语句显式
地返回,并且通常调用者不能忽略对返回的错误的处理
组合
Go 语言为支撑组合的设计提供了类型嵌入(Type Embedding)。通过类型嵌入,我们可
以将已经实现的功能嵌入到新类型中,以快速满足新类型的功能需求,这种方式有些类似
经典面向对象语言中的“继承”机制,但在原理上却与面向对象中的继承完全不同,这是
一种 Go 设计者们精心设计的“语法糖”。
// $GOROOT/src/sync/pool.go
type poolLocal struct {
private interface{}
shared []interface{}
Mutex
pad [128]byte
}
在代码段中,我们在 poolLocal 这个结构体类型中嵌入了类型 Mutex,这就使得
poolLocal 这个类型具有了互斥同步的能力,我们可以通过 poolLocal 类型的变量,直接
调用 Mutex 类型的方法 Lock 或 Unlock。
interface:
type Book struct {
ID string `json:"id"`
Name string `json:"name"`
}
func NewBook() *Book {
return &Book{}
}
// 接口类型
type Store interface {
GetBook(ctx context.Context, id string) (*Book, error)
}
func (s *Book) GetBook(id int) (*Book, error) {
return nil, nil
}
func test() {
NewBook().GetBook(1)
}
并发
并发”这个设计哲学的出现有它的背景,你也知道 CPU 都是靠提高主频来改进性能的,
但是现在这个做法已经遇到了瓶颈。主频提高导致 CPU 的功耗和发热量剧增,反过来制约
了 CPU 性能的进一步提高。2007 年开始,处理器厂商的竞争焦点从主频转向了多核。
在这种大背景下,Go 的设计者在决定去创建一门新语言的时候,果断将面向多核、原生支
持并发作为了新语言的设计原则之一。并且,Go 放弃了传统的基于操作系统线程的并发模
型,而采用了用户层轻量级线程,Go 将之称为 goroutine。
goroutine 占用的资源非常小,Go 运行时默认为每个 goroutine 分配的栈空间仅 2KB。
goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个
Go 程序中可以创建成千上万个并发的 goroutine。而且,所有的 Go 代码都在 goroutine
中执行,哪怕是 go 运行时的代码也不例外。
在提供了开销较低的 goroutine 的同时,Go 还在语言层面内置了辅助并发设计的原语:
channel 和 select。开发者可以通过语言内置的 channel 传递消息或实现同步,并通过
select 实现多路 channel 的并发控制。相较于传统复杂的线程并发模型,Go 对并发的原
生支持将大大降低开发人员在开发并发程序时的心智负担。
入门篇
Go 包的初始化次序
参考图书管理项目:https://github.com/bigwhite/publication/tree/master/column/timegeek/go-first-course/09/bookstore
镜像链接:https://bgithub.xyz/bigwhite/publication/tree/master/column/timegeek/go-first-course
github镜像网站:
初始化一个项目
$mkdir simple-http-server
$cd simple-http-server
$go mod init simple-http-server
todo 有空研究下goframe的启动流程
核心篇
Channel
不带缓冲的channel
ch1 := make(chan int)
ch2 := make(chan int, 5)
第一行我们通过make(chan T)创建的、元素类型为 T 的 channel 类型,是无缓冲
channel,而第二行中通过带有 capacity 参数的make(chan T, capacity)创建的元素
类型为 T、缓冲区长度为 capacity 的 channel 类型,是带缓冲 channel。
channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了
不同的 Goroutine 中。
现在,我们先来看看无缓冲 channel 类型变量(如 ch1)的发送与接收。
由于无缓冲 channel 的运行时层实现不带有缓冲区,所以 Goroutine 对无缓冲 channel
的接收和发送操作是同步的。也就是说,对同一个无缓冲 channel,只有对它进行接收操
作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进
行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态,比如下面示例代码:
func main() {
ch1 := make(chan int)
ch1 <- 13 // fatal error: all goroutines are asleep - deadlock!
n := <-ch1
println(n)
}
运行这个示例,我们就会得到 fatal error,提示我们所有 Goroutine 都处于休眠状态,程
序处于死锁状态。要想解除这种错误状态,我们只需要将接收操作,或者发送操作放到另
外一个 Goroutine 中就可以了,比如下面代码:
func main() {
ch1 := make(chan int)
go func() {
ch1 <- 13 // 将发送操作放入一个新goroutine中执行
}()
n := <-ch1
println(n)
}
带缓冲的channel
和无缓冲 channel 相反,带缓冲 channel 的运行时层实现带有缓冲区,因此,对带缓冲
channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接
收不需要阻塞等待)。
也就是说,对一个带缓冲 channel 来说,在缓冲区未满的情况下,对它进行发送操作的
Goroutine 并不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的 Goroutine
也不会阻塞挂起。
但当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;当缓冲区为空
的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。
ch2 := make(chan int, 1)
n := <-ch2 // 由于此时ch2的缓冲区中无数据,因此对其进行接收操作将导致goroutine挂起
ch3 := make(chan int, 1)
ch3 <- 17 // 向ch3发送一个整型数17
ch3 <- 27 // 由于此时ch3中缓冲区已满,再向ch3发送数据也将导致goroutine挂起
例子
func produce(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i + 1
time.Sleep(time.Second)
}
close(ch)
}
func consume(ch <-chan int) {
for n := range ch {
println(n)
}
}
func TestName90(t *testing.T) {
ch := make(chan int, 5)
var wg sync.WaitGroup
wg.Add(0)
go func() {
produce(ch)
wg.Done()
}()
go func() {
consume(ch)
wg.Done()
}()
wg.Wait()
}
在这个例子中,我们启动了两个 Goroutine,分别代表生产者(produce)与消费者
(consume)。生产者只能向 channel 中发送数据,我们使用chan<- int作为
produce 函数的参数类型;消费者只能从 channel 中接收数据,我们使用<-chan int作
为 consume 函数的参数类型。
在消费者函数 consume 中,我们使用了 for range 循环语句来从 channel 中接收数据,
for range 会阻塞在对 channel 的接收操作上,直到 channel 中有数据可接收或 channel
被关闭循环,才会继续向下执行。channel 被关闭后,for range 循环也就结束了。
produce 函数在发送完数据后,调用 Go 内置的 close 函数关闭了
channel。channel 关闭后,所有等待从这个 channel 接收数据的操作都将返回。发送端负责关闭 channel
select
通过 select,我们可以同时在多个 channel 上进行发送 / 接收操作:
select {
case x := <-ch1: // 从channel ch1接收数据
... ...
case y, ok := <-ch2: // 从channel ch2接收数据,并根据ok值判断ch2是否已经关闭
... ...
case ch3 <- z: // 将z值发送到channel ch3中:
... ...
default: // 当上面case中的channel通信均无法实施时,执行该默认分支
}
当 select 语句中没有 default 分支,而且所有 case 中的 channel 操作都阻塞了的时候,
整个 select 语句都将被阻塞,直到某一个 case 上的 channel 变成可发送,或者某个 case
上的 channel 变成可接收,select 语句才可以继续进行下去。关于 select 语句的妙用,我
们在后面还会细讲,这里我们先简单了解它的基本语法。
同步原语-锁
Go语言中的互斥锁(Mutex)和读写锁(RWMutex)是同步原语,用于在多线程(或协程)环境中控制对共享资源的访问,以确保数据的一致性和完整性。以下是它们的概念、特点和使用案例。
互斥锁(sync.Mutex)
概念: 互斥锁是一种简单的同步机制,它保证在同一时刻,最多只有一个协程能够访问被保护的资源。当一个协程持有互斥锁时,其他试图获取该锁的协程将被阻塞,直到锁被释放。
特点:
排他性:一旦锁被锁定,任何其他尝试获取该锁的操作都将等待,直到锁被当前持有者释放。
非重入:互斥锁不支持递归锁定,即已持有锁的协程不能再次获取同一把锁,否则会导致死锁。
简单易用:通过Lock()方法获取锁,通过Unlock()方法释放锁。通常使用defer语句确保锁总能被释放。
使用案例: 假设有一个全局计数器需要在多个协程中安全地递增:
import (
"sync"
)
var counter int
var mutex = &sync.Mutex{}
func Increment() {
mutex.Lock()
defer mutex.Unlock()
counter++
}
在这个例子中,每次调用Increment函数时,首先会获取互斥锁,确保在修改counter值期间没有其他协程能够同时访问。完成递增操作后,通过defer语句确保锁在函数返回前被释放,使得其他协程可以再次获得锁并更新计数器。
读写锁(sync.RWMutex)
概念: 读写锁是一种更复杂的同步原语,它区分了读操作和写操作。读写锁允许同时有多个读取者访问资源,但当有写入者时,无论是其他写入者还是读取者都无法访问,确保了数据的完整性。
特点:
读写分离:允许多个协程同时进行读取(共享访问),但写入时(独占访问)会阻止所有其他读取和写入。
优先级:写锁优先于读锁。如果有写锁请求正在等待,新的读锁请求会被阻塞,直到所有写锁请求完成。
两种模式:提供RLock()方法获取读锁和Lock()方法获取写锁,分别对应RUnlock()和Unlock()方法释放锁。
使用案例: 考虑一个缓存系统,多个协程可能同时查询数据(读操作),偶尔会有协程更新缓存(写操作):
import (
"sync"
)
type Cache struct {
data map[string]string
lock sync.RWMutex
}
func (c *Cache) Get(key string) string {
c.lock.RLock()
defer c.lock.RUnlock()
return c.data[key]
}
func (c *Cache) Set(key, value string) {
c.lock.Lock()
defer c.lock.Unlock()
c.data[key] = value
}
func (c *Cache) Update(key, newValue string) {
c.lock.Lock()
defer c.lock.Unlock()
if oldValue, ok := c.data[key]; ok {
// 处理旧值...
}
c.data[key] = newValue
}
在这个例子中,Cache结构体维护了一个内部数据字典和一个读写锁。Get方法使用RLock()获取读锁,允许多个协程同时查询不同键的值。而Set和Update方法使用Lock()获取写锁,确保在更新或插入缓存项时没有其他读写操作干扰。同样,通过defer语句确保锁在适当时候被释放。
总结来说,互斥锁适用于对共享资源的所有访问都需要互斥控制的场景,而读写锁则适用于读操作频繁且写操作相对较少的场景,可以提高并发读取的性能。选择使用哪种锁取决于具体的应用需求和数据访问模式。
同步原语-条件变量
sync.Cond
是 Go 语言中的一个并发原语,用于帮助多个 goroutine(Go 语言中的轻量级线程)在满足特定条件时互相协调。简单地说,sync.Cond 允许一个或多个 goroutine 在条件不满足时进入等待状态,直到其他 goroutine 改变条件并发送通知,使得等待的 goroutine 被唤醒并继续执行。
关键概念与方法
条件变量:sync.Cond 类型代表一个条件变量。条件变量的核心思想是,当某个共享状态(或“条件”)不满足时,goroutine 不应继续执行。当条件改变时,负责改变条件的 goroutine 应通知那些等待的 goroutine。
依赖互斥锁:条件变量总是与一个互斥锁(如 sync.Mutex 或 sync.RWMutex)关联。互斥锁用于保护与条件相关的共享状态,防止多个 goroutine 同时修改它,从而导致数据不一致。在操作条件变量之前,必须先获得对应的锁。
cond := sync.NewCond(&sync.Mutex{}) // 创建一个与互斥锁关联的条件变量
关键方法:
Wait():当一个 goroutine 发现条件不满足时,它调用 Wait() 方法。这会自动释放当前持有的互斥锁,使该 goroutine 进入等待状态,并将其添加到条件变量的等待队列中。此时,该 goroutine 会暂停执行,直到收到通知。
Signal():当一个 goroutine 改变了条件,使得至少一个等待的 goroutine 可以继续执行时,它调用 Signal() 方法。这会唤醒等待队列中的一个随机选择的 goroutine。请注意,Signal() 本身不会释放或获取锁;调用者应在调用 Signal() 前后适当地管理锁。
Broadcast():如果条件的改变允许所有等待的 goroutine 继续执行,调用 Broadcast() 方法。这会唤醒等待队列中的所有 goroutine。与 Signal() 一样,Broadcast() 也不涉及锁的管理。
使用场景举例
生产者-消费者模型:假设有一个缓冲区,生产者 goroutine 往里添加数据,消费者 goroutine 从中取出数据处理。当缓冲区为空时,消费者应等待,直到生产者放入数据。同样,当缓冲区满时,生产者应等待,直到消费者取出数据腾出空间。
type Buffer struct {
items []int
count int
size int
cond *sync.Cond
}
func NewBuffer(size int) *Buffer {
b := &Buffer{
items: make([]int, size),
size: size,
count: 0,
}
b.cond = sync.NewCond(&sync.Mutex{})
return b
}
func (b *Buffer) Produce(item int) {
b.cond.L.Lock() // 获取互斥锁
for b.count == b.size { // 如果缓冲区满
b.cond.Wait() // 生产者等待
}
b.items[b.count] = item // 添加新数据
b.count++
b.cond.L.Unlock() // 释放互斥锁
b.cond.Signal() // 通知可能等待的消费者
}
func (b *Buffer) Consume() (int, bool) {
b.cond.L.Lock()
for b.count == 0 { // 如果缓冲区空
b.cond.Wait() // 消费者等待
}
item := b.items[0] // 取出数据
b.items[0] = 0 // 清零(实际应用中可能需要移动其他元素)
b.count--
b.cond.L.Unlock()
b.cond.Signal() // 通知可能等待的生产者
return item, true
}
在这个例子中:
当缓冲区满时,生产者调用 Wait() 进入等待,释放锁以便消费者可以消费。
当缓冲区空时,消费者调用 Wait() 进入等待,释放锁以便生产者可以生产。
生产者或消费者在改变缓冲区状态后,调用 Signal() 或 Broadcast()(这里仅需 Signal())通知对方,使得等待的 goroutine 被唤醒并继续执行。
- 总结
sync.Cond 是一个强大的同步工具,用于在多个 goroutine 之间协调基于特定条件的执行流程。它通过结合互斥锁和等待-通知机制,使得 goroutine 能够在条件不满足时高效地暂停执行,待条件改变后再被唤醒,从而实现复杂的同步逻辑。初学者在掌握互斥锁和基本并发概念后,可以逐渐熟悉并运用 sync.Cond 解决更复杂的问题。
原子操作
a++
这行语句需要 3 条普通机器指令来完成变量 a 的自增:
LOAD:将变量从内存加载到 CPU 寄存器;
ADD:执行加法指令;
STORE:将结果存储回原内存地址中。
这 3 条普通指令在执行过程中是可以被中断的。而原子操作的指令是不可中断的,它就好
比一个事务,要么不执行,一旦执行就一次性全部执行完毕,中间不可分割。也正因为如
此,原子操作也可以被用于共享数据的并发同步。
原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,因此相对于操
作系统层面和 Go 运行时层面提供的同步技术而言,它更为原始。
atomic 包封装了 CPU 实现的部分原子操作指令,为用户层提供体验良好的原子操作函
数,因此 atomic 包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为
高级的并发同步技术,比如 channel 和 sync 包中的同步原语
理解 Go 语言中的原子操作可以从以下几个关键点入手:
- 并发编程的挑战
在编写多线程(或在 Go 中为 goroutine)程序时,多个执行单元可能会同时访问和修改共享的数据。如果没有适当的同步措施,这种并发访问可能导致竞态条件(Race Condition),即不同线程对同一数据的读写操作交错,从而产生难以预料的结果,甚至引发数据错误或程序崩溃。 - 原子操作的概念
原子操作是一种特殊的操作,它保证在执行过程中不会被任何其他线程中断,就像一个不可分割的“原子”一样。这意味着,无论系统中有多少个并发的线程或处理器核心,对某个变量执行原子操作时,该操作从开始到结束都将是连续且完整的,中间不会被打断。 - Go 中的原子操作包
在 Go 语言中,标准库提供了名为 sync/atomic 的包,它包含了一系列函数,允许您对特定类型的变量执行原子操作。这些类型包括:
整数类型:int32, int64, uint32, uint64
指针类型:uintptr 和 unsafe.Pointer
4. 原子操作的类型与示例
以下是 sync/atomic 包中一些基本的原子操作类型及其示例:
交换(Swap)
作用:以原子方式用新值替换变量的现有值,并返回替换前的旧值。
示例:
import "sync/atomic"
var counter int32 = 0 // 假设这是我们要原子修改的计数器
// 原子地将 counter 设置为 10,并返回之前的值
oldValue := atomic.SwapInt32(&counter, 10)
在这个例子中,atomic.SwapInt32 会将 counter 的值从 0 改为 10,并返回 0(即替换前的值)。
比较并交换(Compare-and-Swap, CAS)
作用:原子地检查变量的当前值是否等于预期值,如果是,则用新值替换变量。同时返回一个布尔值,表示替换是否成功。
示例:
import "sync/atomic"
var sharedValue int32 = 0 // 假设这是我们需要原子更新的共享变量
// 原子地检查 sharedValue 是否为 0,如果是,则将其设置为 1
ok := atomic.CompareAndSwapInt32(&sharedValue, 0, 1)
if ok {
fmt.Println("Value was 0 and successfully set to 1")
} else {
fmt.Println("Value was not 0, so it wasn't modified")
}
这里,atomic.CompareAndSwapInt32 会检查 sharedValue 是否为 0。如果是,则将其改为 1,并返回 true;否则,不做改动并返回 false。
增加/减少(Add/Subtract)
作用:以原子方式对变量进行指定数值的增加或减少,并返回操作后的值。
示例(仅展示增加):
import "sync/atomic"
var counter uint64 = 0 // 假设这是一个需要原子递增的计数器
// 原子地将 counter 增加 1,并返回增加后的值
newValue := atomic.AddUint64(&counter, 1)
atomic.AddUint64 会将 counter 的值增加 1,并返回新的值。
载入(Load)
作用:以原子方式读取变量的当前值,确保读取过程中不会被其他并发写入干扰。
示例:
import "sync/atomic"
var sharedValue int32 = 42 // 假设这是我们要原子读取的共享变量
// 原子地读取 sharedValue 的当前值
value := atomic.LoadInt32(&sharedValue)
atomic.LoadInt32 会安全地获取 sharedValue 当前的值。
存储(Store)
作用:以原子方式将新值写入变量,确保写入过程中不会被其他并发读取或写入干扰。
示例:
import "sync/atomic"
var sharedValue int32 = 0 // 假设这是我们要原子修改的共享变量
// 原子地将 sharedValue 设置为 42
atomic.StoreInt32(&sharedValue, 42)
atomic.StoreInt32 将 sharedValue 的值设置为 42,整个过程不会被其他线程中断。
- 应用与注意事项
原子操作在以下场景非常有用:
无锁计数器:如统计请求次数、管理资源引用计数等。
简单状态标志:如标记任务完成、检查条件是否满足等。
安全更新指针:在多线程环境中指向共享资源的指针更新。
注意:
原子操作仅适用于对基本数据类型的简单并发修改,对于复杂的同步逻辑,可能需要使用互斥锁(如 sync.Mutex)、读写锁(sync.RWMutex)或通道(chan)等同步机制。
过度依赖或滥用原子操作可能导致代码难以理解和维护。在设计并发程序时,应选择最合适的同步手段,保持代码简洁且易于理解。
总之,Go 中的原子操作是并发编程中一种强大的工具,它能帮助您在不引入额外锁机制的情况下,安全、高效地更新共享变量,避免竞态条件。通过使用 sync/atomic 包提供的函数,您可以轻松实现诸如计数、状态变更、指针更新等操作,确保这些操作在多线程环境中的原子性。
泛型
todo
实战篇
todo 基于 TCP 的自定义应用层协议的通信服务端
参考资料
1.《极客时间TonyBai go语言第一课》