一、并发编程

并发是指在同一时间间隔内,有多个程序或线程同时执行的情况。并发编程是一种编程方式,注重多个任务同时执行,使得这些任务看上去好像是同时发生的。在并发编程中,多个程序执行流在同一时间段内运行。因为程序的执行是不可确定的,所以在并发编程中,多个线程之间需要相互协调,以避免竞争条件和死锁等问题。

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