前言
最近面试某个大厂,结果,问一个不会一个,并且编程题还写出了笑话,遭到了嘲笑。
是这样一个问题:
创建3个线程,按顺序依次打印1-100.即线程1打印1,线程2打印2,线程3打印3,然后线程1打印4,线程2打印5,线程3打印6......
其实问题不难,但是由于自己当时对 Go 不熟练,就写出了笑话,管道的操作符号(<-)写成了(->),对面的面试官笑的都憋不住了,唉,感觉受到了伤害。但是还要面对现实,看看这个问题怎么解决吧。
问题分析
其实这种问题本质是一个线程同步问题,通过线程同步工具,保证在并发环境下使得程序按序执行。
不同语言提供的线程同步工具有很多,最常见的就是锁,比如 Java 就有十多种锁可提供使用,如互斥锁,自旋锁,可重入锁,栅栏,信号量等。这里主要使用 Go 的互斥锁以及 channel 来提供 2 种解决方案。
实现方案
在讨论实现方案之前,一定要清楚一个线程相关的知识点:
对于普通的互斥锁,不强制之前对其加锁的线程来释放,可由其他获得 CPU 执行的线程来释放。
那么我们首先把问题拆解下,首先分析两个线程该如何打印。一个线程打印的是奇数,另一线程打印的是偶数,按照奇偶顺序打印。
锁
线程要顺序执行,最常见的思路是通过锁来保证线程的同步顺序,只有线程获得锁才能完成执行动作,那么对于这个问需要设置 1 把锁还是 2 把锁呢?首先看设置 1 把锁的逻辑。
假设,线程1先获得锁,可以设置一个标识,表明另一个线程是否执行完成,如果应该线程1应该执行,就打印数字,并设置标识下次应该由线程2执行;否则释放锁,由线程1和线程2继续抢占,直到线程2获得锁执行,之后重复这样的逻辑。代码如下。
package main
import (
"fmt"
"sync"
"time"
)
type FooBar struct {
mu sync.Mutex
wg sync.WaitGroup
firstJobDone bool
}
func (fb *FooBar) printFirst() {
for cnt := 1; cnt <= 100; {
fb.mu.Lock()
if fb.firstJobDone {
fb.mu.Unlock()
} else if cnt % 2 == 1 {
fmt.Printf("Goroutine-1: %d\n", cnt)
cnt += 2
fb.firstJobDone = true
fb.mu.Unlock()
}
}
}
func (fb *FooBar) printSecond() {
for cnt := 2; cnt <= 100; {
fb.mu.Lock()
if !fb.firstJobDone {
fb.mu.Unlock()
} else if cnt % 2 == 0 {
fmt.Printf("Goroutine-2: %d\n", cnt)
cnt += 2
fb.firstJobDone = false
fb.mu.Unlock()
}
}
}
func main() {
obj := FooBar{}
go obj.printFirst()
go obj.printSecond()
obj.wg.Wait()
// 一定要设置time.Sleep(),防止主线程退出后导致子协程没有执行完,程序就结束了
time.Sleep(time.Second * 10)
}
复制代码
但是这样实现有明显的问题,就是抢占到线程后,发现不应该执行又释放线程,效率就会变得很差。所以,再看下设置2把锁的情况。
为了保证线程1先执行,首先锁定线程2。线程1获得锁后,打印,释放线程2的锁;线程2获得锁,打印,释放线程1的锁。这样就不会出现无效获得锁释放锁的问题。
那么把问题拓展到 3 个线程顺序打印,也就是使用 3 把锁就可以了,通过一个线程的执行去设置另外两个线程的执行权力。接下来看代码。
package main
import (
"fmt"
"sync"
"time"
)
type FooBar struct {
mutexFirst sync.Mutex
mutexSecond sync.Mutex
mutexThird sync.Mutex
wg sync.WaitGroup
}
func (fb *FooBar) construct() {
// 先让线程2和线程3不能执行
fb.mutexSecond.Lock()
fb.mutexThird.Lock()
}
func (fb *FooBar) PrintFirst(pool int) {
for i := 1; i <= pool; i += 3 {
fb.mutexFirst.Lock()
fmt.Printf("Goroutine-1: %d\n", i)
fb.mutexSecond.Unlock()
}
}
func (fb *FooBar) PrintSecond(pool int) {
for i := 2; i <= pool; i += 3 {
fb.mutexSecond.Lock()
fmt.Printf("Goroutine-2: %d\n", i)
fb.mutexThird.Unlock()
}
}
func (fb *FooBar) PrintThird(pool int) {
for i := 3; i <= pool; i += 3 {
fb.mutexThird.Lock()
fmt.Printf("Goroutine-3: %d\n", i)
fb.mutexFirst.Unlock()
}
}
func main() {
obj := FooBar{}
obj.construct()
pool := 100
go obj.PrintFirst(pool)
go obj.PrintSecond(pool)
go obj.PrintThird(pool)
obj.wg.Wait()
time.Sleep(time.Second * 10)
}
复制代码
运行效果:
Goroutine-1: 1
Goroutine-2: 2
Goroutine-3: 3
Goroutine-1: 4
Goroutine-2: 5
Goroutine-3: 6
Goroutine-1: ...
Goroutine-2: ...
Goroutine-3: ...
Goroutine-2: 98
Goroutine-3: 99
Goroutine-1: 100
复制代码
管道
Go 提供了另一种并发模型(CSP),可以利用 channel 来实现协程间的通信。接下里使用无缓冲 chanenl 来实现同样的功能。
package main
import (
"fmt"
"time"
)
var POOL = 100
// 为什么每一个goroutine都要从从1运行到100呢?
// 因为每个数字都要传递到3个协程当中,如果有跳跃式的遍历,那么会导致后面的协程缺少数字
func printFirst(p chan int, r chan int) {
for i := 1; i <= POOL; i++ {
p <- i
if i%3 == 1 {
fmt.Println("groutine-1:", i)
}
<-r
}
}
func printSecond(p chan int, q chan int) {
for i := 1; i <= POOL+1; i++ {
<-p
if i%3 == 2 {
fmt.Println("groutine-2:", i)
}
q <- i
}
}
func printThird(q chan int, r chan int) {
for i := 1; i <= POOL; i++ {
<-q
if i%3 == 0 {
fmt.Println("groutine-3:", i)
}
r <- i
}
}
func main() {
msg := make(chan int)
msg2 := make(chan int)
msg3 := make(chan int)
go printFirst(msg, msg3)
go printSecond(msg, msg2)
go printThird(msg2, msg3)
time.Sleep(time.Second * 2)
}
复制代码
总结
并发编程是一个很大的话题,每种语言有自己的解决方案,为了应付面试,建议还是多刷刷 leetcode。