一、并发编程
并发是指在同一时间间隔内,有多个程序或线程同时执行的情况。并发编程是一种编程方式,注重多个任务同时执行,使得这些任务看上去好像是同时发生的。在并发编程中,多个程序执行流在同一时间段内运行。因为程序的执行是不可确定的,所以在并发编程中,多个线程之间需要相互协调,以避免竞争条件和死锁等问题。
goroutinechannel
二、Goroutine
goroutine是 Go 语言中的一种轻量级线程实现,它可以在单一的操作系统线程中并发地执行多个任务。与传统的线程不同的是,goroutine 的初始栈很小(只有2KB),它们的调度和管理不是由操作系统内核,而是由Go运行时系统自己实现的,因此,goroutine 可以比线程更高效地使用内存和CPU资源,同时也更容易实现。在Go程序中非常常见,同一个程序中可能会有成千上万个 goroutine 在并发执行。
1.创建Goroutine
Go语言中创建goroutine非常容易:只需在函数或方法调用前加上 go 关键字,就可以将该函数作为一个新的 goroutine 启动并发执行。s首先让我们看一段正常代码:
func sayHello() {
fmt.Println("Hello!")
}
func main() {
sayHello()
fmt.Println("主程序执行完毕...")
}
人脑编译运行一下,会发现这段程序输出两行字符串,第一行是"Hello!",第二行是"主程序执行完毕..."
sayHello()sayHello()go
func sayHello() {
fmt.Println("Hello!")
}
func main() {
go sayHello()
fmt.Println("主程序执行完毕...")
}
按照我们的想法来看,控制台应该依旧输出相同的结果,但是运行了无数遍后,发现控制台只输出"主程序执行完毕..."。
我疑惑,我不解,我哇啦哇啦哭。
sayHello
所以解决这个问题很简单,让main goroutine晚一会结束,就可以达到预期的效果了,因此,让main沉睡一秒吧!
func main() {
go sayHello()
fmt.Println("主程序执行完毕...")
time.Sleep(time.Second)
}
最后,控制台如我们所愿的打印了两行预期的字符串。
2.项目实战
go
server/main.go
func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
go handleConn(conn)
}
}
func handleConn(c net.Conn) {
defer c.Close()
for {
_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
if err != nil {
return
}
time.Sleep(1 * time.Second)
}
}
handleConnhandleConn
client/main.go
func main() {
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
mustCopy(os.Stdout, conn)
}
func mustCopy(dst io.Writer, src io.Reader) {
if _, err := io.Copy(dst, src); err != nil {
log.Fatal(err)
}
}
mustCopy()
server/main.goclient/main.go
三、WaitGroup
sayHello()sync.WaitGroupAdd()Done()Wait()
var wg sync.WaitGroup
func sayHello(i int) {
defer wg.Done() //在登记表中删除协程
fmt.Println("Hello,go routine!", i)
}
func main() {
for i := 0; i < 1000; i++ {
wg.Add(1) //将协程填入登记表
go sayHello(i)
}
fmt.Println("main done!")
wg.Wait() //阻塞,等待小弟干完活
}
wg.Add(1)sayHello()wg.Done()wg.Wait()
channel
四、Channel
channel是 Go语言并发编程中安全通信的重要手段之一,它提供了goroutine间的通信机制。channel类似于传统意义上的管道,采用先进先出的方式,可以在多个 goroutine 之间传递数据。通过 channel,不同的 goroutine 可以安全地发送和接收数据,避免了并发访问共享内存带来的问题,从而简化了并发编程的难度。
1.创建Channel
chan
ch := make(chan int)
2.Channel的操作
chan
ch <- 10 //将10发送到ch中
x := <-ch //从ch中发送一个数据赋给x
<-ch //从ch中发送数据,不接收
close(ch) //关闭ch
3.Channel的缓存
chan
ch1 := make(chan int) //不带缓存
ch2 := make(chan int,0) //不带缓存
ch3 := make(chan int,2) //缓存为2
ch1
ch1 <- 10
deadlock
func main() {
ch1 := make(chan int, 0)
defer close(ch1)
go func() {
val := <-ch1
fmt.Printf("ch1中的值为%d", val)
}()
ch1 <- 10
time.Sleep(time.Second)
}
一开始时我对这段代码有些疑问,为什么要先执行这个goroutine再执行ch1<-10呢,正常的逻辑不应该是先把10发送到ch1中,再执行goroutine接收这个10吗?于是我把两者的执行顺序调换了一下运行,发现是死锁。在经过一段时间的苦思冥想后,俺明白了原因。
ch1
ch1
反过来也一样,直接给出以下代码:
func main() {
ch1 := make(chan int, 0)
defer close(ch1)
go func() {
ch1 <- 10
}()
val := <-ch1
fmt.Printf("ch1中的值为%d\n", val)
time.Sleep(time.Second)
}
这种无缓存Channel的发送和接收操作将导致两个goroutine做一次同步操作。因为这个原因,无缓存Channel有时候也被称为同步Channel。
ch3
func f(ch chan int) {
for v := range ch {
fmt.Println(v)
}
}
func main() {
ch3 := make(chan int, 2)
ch3 <- 1
ch3 <- 2
close(ch3)
f(ch3)
}
range
4.单向Channel
在某些场景下,我们可能会将Channel作为参数在多个任务函数间进行传递,通常会选择在不同的任务函数中对Channel的使用进行限制,比如限制Channel在某个函数中只能执行发送或只能执行接收操作。针对这种情况,go语言中提供了对channel的以下定义:
<- chan int // 只接收通道,只能接收不能发送
chan <- int // 只发送通道,只能发送不能接收
countersquarerprinter
func counter(out chan<- int) {
for x := 0; x < 100; x++ {
out <- x
}
close(out)
}
func squarer(out chan<- int, in <-chan int) {
for v := range in {
out <- v * v
}
close(out)
}
func printer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
func main() {
naturals := make(chan int)
squares := make(chan int)
go counter(naturals)
go squarer(squares, naturals)
printer(squares)
}
5.Select多路复用
在某些场景下我们可能需要同时从多个channel接收数据。但channel在接收数据时,如果没有数据可以被接收,那么当前 goroutine将会发生阻塞。然而你用以下代码反驳了我:
for{
// 尝试从ch1接收值
data, ok := <-ch1
// 尝试从ch2接收值
data, ok := <-ch2
…
}
Select
select {
case <-ch1:
//...
case data := <-ch2:
//...
case ch3 <- 10:
//...
default:
//默认操作
}
Selectswitch-case
- 可处理一个或多个 channel 的发送/接收操作。
- 如果多个 case 同时满足,select 会随机选择一个执行。
- 对于没有 case 的 select 会一直阻塞,可用于阻塞 main 函数,防止退出。
接下来看一段比较有意思的代码
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x) // "0" "2" "4" "6" "8"
case ch <- i:
}
}
首先定义一个缓存为1的channel,进入for循环后采用switch多路复用,ch中能取出值时,就赋给x并打印,取不出时,就把当前循环到的i发送进ch。总的来说,代码的作用是在循环中交替发送和接收数据,每当有数据被接收和打印时,就会通过通道空出一个缓冲区位置,以便于下一个操作能够进行。
五、并发安全和锁
谈起并发,不可避免的就是并发带来的弊端:各个协程会对数据进行争夺,就好像火车上很多人同时抢一个厕所一样。下面让我们用一个比较直观的例子展示:
var (
x int
wg sync.WaitGroup
)
func add() {
for i := 0; i < 5000; i++ {
x++
}
wg.Done()
}
func main() {
wg.Add(4)
go add()
go add()
go add()
go add()
wg.Wait()
fmt.Println(x)
}
我在看到这段代码的时候,人脑编译并运行了一下,感觉结果怎么都得是20000,因为它无非就是对x执行了4次自增5000的操作。但当我运行后,发现每次的结果都不一样,有时是20000,但更多的是如14805、14638等异常情况。原因就是我们开启了四个协程,它们对全局变量x产生了争夺。举例来说,例如协程A拿到了x,把它加到了200,这个时候协程B刚刚醒困,瞄了一眼x现在是200,准备操作但还没来得及操作的时候,协程A把x加到了5000并退出,这个时候协程B开始累加x,但是因为它记录的值是200,所以从200开始累加5000次,最终x变成5200,而不是预想的10000,相当于B对x的修改覆盖了A对x的修改。
1.互斥锁
针对以上问题,需要一种机制来保护全局变量x,具体表现为:在某个协程对x进行修改的时候,其他的协程不允许碰x,直到此协程对x的修改结束后,其他协程才可以拿到x继续修改。这时,互斥锁横空出世!
syncMutexLock()Unlock()
var (
x int
wg sync.WaitGroup
lock sync.Mutex //互斥锁 只有一个goroutine可以拿到
)
func add() {
for i := 0; i < 5000; i++ {
lock.Lock()
x++
lock.Unlock()
}
wg.Done()
}
func main() {
wg.Add(4)
go add()
go add()
go add()
go add()
wg.Wait()
fmt.Println(x)
}
Lock()Unlock()
2.读写锁
在一些常用场景下,会发现对数据的修改操作没那么频繁,更多的是对数据的查询操作,例如支付宝的余额查询等。这个时候加互斥锁就有些影响性能了,因为“读”这个操作相对安全,并没有涉及到对数据的修改,可以进行并发操作,而互斥锁同样会给“读”操作带来限制,一次只能有一个协程读,这不太优雅。这时,读写锁横空出世!
syncRWMutexLock()Unlock()RLock()RUnlock()RLocker
- 读锁:允许多个goroutine同时读取共享资源,但阻止任何一个goroutine修改(写入)资源。多个读取操作之间不存在互斥关系,因此并发度高,吞吐量也高。读操作过程中不会破坏数据结构的完整性。
- 写锁:只允许一个goroutine进行写操作,同时它也阻止其他goroutine进行读或写操作。写锁用于保护写操作过程中的数据完整性,当数据需要修改时,确定只有一个goroutine对它进行修改,避免数据被多个goroutine同时修改而发生错误。
六、总结
go