Golang实现并发编程 | 青训营

一、并发编程

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

在Go语言中,一般采用goroutinechannel实现并发编程。

二、Goroutine

goroutine是 Go 语言中的一种轻量级线程实现,它可以在单一的操作系统线程中并发地执行多个任务。与传统的线程不同的是,goroutine 的初始栈很小(只有2KB),它们的调度和管理不是由操作系统内核,而是由Go运行时系统自己实现的,因此,goroutine 可以比线程更高效地使用内存和CPU资源,同时也更容易实现。在Go程序中非常常见,同一个程序中可能会有成千上万个 goroutine 在并发执行。

1.创建Goroutine

Go语言中创建goroutine非常容易:只需在函数或方法调用前加上 go 关键字,就可以将该函数作为一个新的 goroutine 启动并发执行。s首先让我们看一段正常代码:

go 复制代码
func sayHello() {
	fmt.Println("Hello!")
}

func main() {
	sayHello()
	fmt.Println("主程序执行完毕...")
}

人脑编译运行一下,会发现这段程序输出两行字符串,第一行是"Hello!",第二行是"主程序执行完毕..."

接着,让我们采用goroutine的方式运行sayHello(),即在sayHello()前面添加go关键字:

go 复制代码
func sayHello() {
	fmt.Println("Hello!")
}

func main() {
	go sayHello()
	fmt.Println("主程序执行完毕...")
}

按照我们的想法来看,控制台应该依旧输出相同的结果,但是运行了无数遍后,发现控制台只输出"主程序执行完毕..."。

我疑惑,我不解,我哇啦哇啦哭。

原来在 Go 程序启动时,就会为 main 函数创建一个默认的 goroutine 。在上面的代码中我们在 main 函数中使用 go 关键字创建了另外一个 goroutine 去执行 sayHello函数,而此时 main goroutine 还在继续往下执行,我们的程序中此时存在两个并发执行的 goroutine。当 main 函数结束时整个程序也就结束了,同时 main goroutine 也结束了,所有由 main goroutine 创建的 goroutine 也会一同退出。也就是说我们的 main 函数退出太快,另外一个 goroutine 中的函数还未执行完程序就退出了,导致未打印出"Hello"。

所以解决这个问题很简单,让main goroutine晚一会结束,就可以达到预期的效果了,因此,让main沉睡一秒吧!

go 复制代码
func main() {
	go sayHello()
	fmt.Println("主程序执行完毕...")
	time.Sleep(time.Second)
}

最后,控制台如我们所愿的打印了两行预期的字符串。

2.项目实战

懂得用go关键字开启协程以后,可以做一个小项目,分为客户端和服务端。服务端被动等待客服端连接,如果有客户端连接,就把当前的时间发送给客户端,客户端的任务就很简单了,将服务端发来的时间显示出来即可。

先看服务端server/main.go的代码

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)
	}
}

非常好理解,服务端以tcp方式监听8000端口,有错误就打印,没错误就继续往下走,进入一个死循环,不停的等待客户端连接它。一旦收到了客户端的连接,就开启协程执行handleConn方法,handleConn方法也是通过死循环的方式,把当前的时间发送给客户端,每秒执行一次,接着关闭连接。

接下来看client/main.go客户端的代码:

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)
	}
}

客户端使用tcp方式对8000端口创建连接,调用mustCopy()方法从连接对象读取数据,并将读取到的数据输出到标准输出(os.Stdout),十分简单。

先运行server/main.go,再运行client/main.go会看到控制台的输出:

三、WaitGroup

上面的sayHello()简单例子虽然达到了效果,但是及其不优雅,哪有让主协程沉睡来等待其他协程执行完毕的啊,所以可以采用sync.WaitGroup实现协程的优雅执行。可以把它理解为一个协程的登记表,每有一个协程诞生,就用Add()方法在表中登记一个,当有协程执行成功了,就用Done()方法把它从登记表中删除,如果还有协程没有执行完,就用Wait()方法等待,请看以下代码:

go 复制代码
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() //阻塞,等待小弟干完活
}

首先用了一个for循环开启了1000个协程,每开一个协程,就执行wg.Add(1)登记协程,在sayHello()函数执行完后,会使用wg.Done()从登记表中删除一个协程,然后调用wg.Wait()等待所有协程从登记表中删除,即所有协程执行完,退出程序。

但是以上代码还存在着一些不足,就是除了从主函数退出或者直接终止程序之外,没有其它的方法能够让一个goroutine来打断另一个的执行。在介绍这个神奇的方法前,让我们先了解一下channel

四、Channel

channel是 Go语言并发编程中安全通信的重要手段之一,它提供了goroutine间的通信机制。channel类似于传统意义上的管道,采用先进先出的方式,可以在多个 goroutine 之间传递数据。通过 channel,不同的 goroutine 可以安全地发送和接收数据,避免了并发访问共享内存带来的问题,从而简化了并发编程的难度。

1.创建Channel

chan可以使用make关键字创建,它也有用数据类型,表示通道中传递数据的类型。

go 复制代码
ch := make(chan int)

2.Channel的操作

chan拥有三种操作,分别是:发送、接收、关闭,应用如下所示:

go 复制代码
ch <- 10  //将10发送到ch中
x := <-ch //从ch中发送一个数据赋给x
<-ch  //从ch中发送数据,不接收
close(ch)  //关闭ch

3.Channel的缓存

先前的语句在使用make创建chan时,默认创建出一个不带缓存的channel,可以通过指定第二个参数的方式,来创建有缓存的channel。

go 复制代码
ch1 := make(chan int)  //不带缓存
ch2 := make(chan int,0)  //不带缓存
ch3 := make(chan int,2)  //缓存为2

对于上面定义好的ch1来说,它是一个无缓存channel。如果执行以下代码会发生什么呢?

go 复制代码
ch1 <- 10

答案是程序会发生deadlock死锁。因为ch1的缓存为0,换句话说就是它没有空间存储这个10,一直在等待另一个 goroutine 接收该值,进入阻塞状态,导致死锁。因此需要另一个goroutine来接收这个10:

go 复制代码
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<-10,主程序就进入阻塞状态,等待另一个goroutine把这个10收走,所以根本就无法执行这一段go func(){xxxx},因为此时主程序已经阻塞了,所以死锁错误。

而我给出的这段代码示例,首先开启一个goroutine等待接收ch1中的值,这个时候主程序正在开启goroutine,然后执行了ch1<-10,进入阻塞状态,而这个时候goroutine恰好开启成功,顺利的接收走了这个10,因此成功运行。

反过来也一样,直接给出以下代码:

go 复制代码
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这种有缓存的channel,就不存在上述问题,对于它的操作可以用以下代码:

go 复制代码
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关键字来遍历ch3中的值。

4.单向Channel

在某些场景下,我们可能会将Channel作为参数在多个任务函数间进行传递,通常会选择在不同的任务函数中对Channel的使用进行限制,比如限制Channel在某个函数中只能执行发送或只能执行接收操作。针对这种情况,go语言中提供了对channel的以下定义:

go 复制代码
<- chan int // 只接收通道,只能接收不能发送
chan <- int // 只发送通道,只能发送不能接收

接下来让我们写一个channel互连的程序,要求counter作为计数器,产生0-100的数,squarer能够将这些数值进行平方运算,printer则将运算后的数值打印到控制台,因此有以下代码:

go 复制代码
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将会发生阻塞。然而你用以下代码反驳了我:

go 复制代码
for{
    // 尝试从ch1接收值
    data, ok := <-ch1
    // 尝试从ch2接收值
    data, ok := <-ch2
    ...
}

这没毛病,可以实现,但是代码的效率太低。因此,go提供了Select关键字实现多路复用,具体格式如下:

go 复制代码
select {
case <-ch1:
	//...
case data := <-ch2:
	//...
case ch3 <- 10:
	//...
default:
	//默认操作
}

Select语句和switch-case语句十分类似,具有以下特点。

  • 可处理一个或多个 channel 的发送/接收操作。
  • 如果多个 case 同时满足,select 会随机选择一个执行。
  • 对于没有 case 的 select 会一直阻塞,可用于阻塞 main 函数,防止退出。

接下来看一段比较有意思的代码

go 复制代码
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。总的来说,代码的作用是在循环中交替发送和接收数据,每当有数据被接收和打印时,就会通过通道空出一个缓冲区位置,以便于下一个操作能够进行。

五、并发安全和锁

谈起并发,不可避免的就是并发带来的弊端:各个协程会对数据进行争夺,就好像火车上很多人同时抢一个厕所一样。下面让我们用一个比较直观的例子展示:

go 复制代码
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继续修改。这时,互斥锁横空出世!

互斥锁能够保证同一时间只有一个goroutine可以访问共享资源。Go 语言中使用sync包中提供的Mutex类型来实现互斥锁,它拥有两个方法Lock()Unlock()分别为锁的获取和释放,接下来用互斥锁来修改上面的例子:

go 复制代码
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)
}

在对x执行修改操作的时候,用Lock()给它上一把锁,修改完毕后Unlock()把锁释放。这时无论运行多少次,x的值也只会是20000,掌声在哪里!!!

2.读写锁

在一些常用场景下,会发现对数据的修改操作没那么频繁,更多的是对数据的查询操作,例如支付宝的余额查询等。这个时候加互斥锁就有些影响性能了,因为"读"这个操作相对安全,并没有涉及到对数据的修改,可以进行并发操作,而互斥锁同样会给"读"操作带来限制,一次只能有一个协程读,这不太优雅。这时,读写锁横空出世!

读写锁在 Go 语言中使用sync包中的RWMutex类型,它拥有五个方法,分别是:Lock()获取写锁、Unlock()释放写锁、RLock()获取读锁、RUnlock()释放读锁、RLocker返回一个实现Locker接口的读写锁。

  • 读锁:允许多个goroutine同时读取共享资源,但阻止任何一个goroutine修改(写入)资源。多个读取操作之间不存在互斥关系,因此并发度高,吞吐量也高。读操作过程中不会破坏数据结构的完整性。
  • 写锁:只允许一个goroutine进行写操作,同时它也阻止其他goroutine进行读或写操作。写锁用于保护写操作过程中的数据完整性,当数据需要修改时,确定只有一个goroutine对它进行修改,避免数据被多个goroutine同时修改而发生错误。

六、总结

不得不说,使用go来实现高并发真的是太方便了,仅仅只需要增加一个go关键字即可,对于各个协程的管理、通信,还有现成的channel可以用,真是语言层面的巨大优势啊(说实话我都忘了java怎么实现多线程了)。希望自己能在学go的道路上坚持下去!

相关推荐
千慌百风定乾坤18 小时前
Go 语言入门指南:基础语法和常用特性解析(下) | 豆包MarsCode AI刷题
青训营笔记
FOFO18 小时前
青训营笔记 | HTML语义化的案例分析: 粗略地手绘分析juejin.cn首页 | 豆包MarsCode AI 刷题
青训营笔记
滑滑滑2 天前
后端实践-优化一个已有的 Go 程序提高其性能 | 豆包MarsCode AI刷题
青训营笔记
柠檬柠檬3 天前
Go 语言入门指南:基础语法和常用特性解析 | 豆包MarsCode AI刷题
青训营笔记
用户967136399653 天前
计算最小步长丨豆包MarsCodeAI刷题
青训营笔记
用户52975799354723 天前
字节跳动青训营刷题笔记2| 豆包MarsCode AI刷题
青训营笔记
clearcold4 天前
浅谈对LangChain中Model I/O的见解 | 豆包MarsCode AI刷题
青训营笔记
夭要7夜宵4 天前
【字节青训营】 Go 进阶语言:并发概述、Goroutine、Channel、协程池 | 豆包MarsCode AI刷题
青训营笔记
用户336901104444 天前
数字分组求和题解 | 豆包MarsCode AI刷题
青训营笔记
dnxb1234 天前
GO语言工程实践课后作业:实现思路、代码以及路径记录 | 豆包MarsCode AI刷题
青训营笔记