Golang并发编程 | 青训营

148 阅读5分钟

Golang并发编程

在Golang中,并没有直接创建线程的概念,而是使用协程(goroutine)来实现并发。协程是Golang提供的一种轻量级并发机制,它们由Go运行时系统进行管理,可以在一个或多个操作系统线程上运行。

要创建一个协程(goroutine),只需在函数调用前添加关键字go即可。这将告诉Go编译器将该函数调用包装成一个协程,并在一个可用的操作系统线程上运行。那么为什么Golang要用协程而不是直接用系统调用提供的线程呢?

1创建和销毁的成本:

协程(goroutine):

在Golang中,创建和销毁协程的成本非常低。协程是由Go运行时系统进行管理的,它们可以轻松地启动和关闭,而且启动一个新的协程几乎没有额外的开销。

线程(thread):

创建和销毁线程的成本相对较高。线程的创建和销毁涉及操作系统级别的资源分配和管理,因此相对较慢。

2并发数量:

协程(goroutine):

Golang中的协程可以创建数千甚至数百万个,因为它们的资源消耗很小。

线程(thread):

创建过多的线程可能会导致内存和资源的耗尽,因为每个线程都需要一定的内存和操作系统资源。

3调度和切换:

协程(goroutine):

协程的调度由Go运行时系统负责。Go运行时使用一种称为"M:N调度"的技术,将M个协程映射到N个操作系统线程上。这使得协程的切换更加轻量级,因为它们在用户空间中调度。

线程(thread):

线程的调度由操作系统负责。线程切换需要从用户空间切换到内核空间,这涉及较大的开销。

4共享内存通信:

协程(goroutine):

在Golang中,协程通过通道(channel)进行通信和同步。通道提供了一种安全的方式来在协程之间传递数据,避免了多个协程访问共享内存可能引发的竞态条件等问题。

线程(thread):

线程之间共享内存时需要使用显式的同步机制,如互斥锁(mutex)和条件变量(condition variable),以避免数据竞争。

小结:

这些是关于Golang中的协程和我们常见于别的语言中线程使用的一些区别和优势。综合来说,Golang的协程在轻量级并发方面表现出色,适用于处理大量并发任务,而线程通常更适合于需要直接访问操作系统资源的场景。接下来我们会通过一些具体的例子来展示Golang中协程和管道具体是怎么用的。

Golang管道的使用:

例1:不包含缓存的管道

package main
import "fmt"
func main() {
    messages := make(chan string)
    go func() { messages <- "ping" }()
    msg := <-messages
    fmt.Println(msg)
}

返回值:

$ go run channels.go 
ping

当我们运行程序时,"ping"消息通过我们的通道成功从一个 goroutine 传递到另一个 goroutine。默认情况下,发送和接收会阻塞,直到发送者和接收者都准备好为止。此属性允许我们在程序结束时等待消息,"ping" 而无需使用任何其他同步。所以我们不必担心在发消息的协程结束前主协程提前结束。

例2:包含缓存的管道

package main
import "fmt"
func main() {
    messages := make(chan string, 2)
    messages <- "buffered"
    messages <- "channel"
    fmt.Println(<-messages)
    fmt.Println(<-messages)
}

返回值:

$ go run channel-buffering.go 
buffered
channel

这里我们有make一个字符串通道,最多可以缓冲 2 个值。由于该通道是缓冲的,因此我们可以将这些值发送到通道中,而无需相应的并发接收。稍后我们就可以像往常一样接收这两个值。

例3:通道同步

package main
import (
    "fmt"
    "time"
)
func worker(done chan bool) {
    fmt.Print("working...")
    time.Sleep(time.Second)
    fmt.Println("done")
    done <- true
}
func main() {
    done := make(chan bool, 1)
    go worker(done)
    <-done
}

返回值:

$ go run channel-synchronization.go      
working...done  

我们可以使用通道来同步各个 goroutine 的执行。上面是使用阻塞接收来等待 goroutine 完成的示例。当等待多个 goroutine 完成时,使用waitGroup通常是一个更好的选择。

例4:通道关闭

package main
import "fmt"
func main() {
    jobs := make(chan int, 5)
    done := make(chan bool)
    go func() {
        for {
            j, more := <-jobs
            if more {
                fmt.Println("received job", j)
            } else {
                fmt.Println("received all jobs")
                done <- true
                return
            }
        }
    }()
    for j := 1; j <= 3; j++ {
        jobs <- j
        fmt.Println("sent job", j)
    }
    close(jobs)
    fmt.Println("sent all jobs")
    <-done
}

返回值:

$ go run closing-channels.go 
sent job 1
received job 1
sent job 2
received job 2
sent job 3
received job 3
sent all jobs
received all jobs 

在此示例中,我们将使用jobs通道将要完成的工作从 main() Goroutine 传递到 Worker Goroutine。 当我们没有更多的jobs可供Worker使用时,我们将关闭jobs通道。工人 goroutine, 重复接收来自 j, more := <-jobs 的值。 在这种特殊的 2 值形式的接收中,如果jobs已关闭且通道中的所有值均已收到,则 more 值将为 false。 当我们完成所有工作时,我们用它来通知完成情况。需要注意的是这段代码实际上sent all jobs和received all jobs的出现顺序并不是确定的这取决于系统对于这两个协程的调度顺序。

例5:用for和range来循环接受通道中的值

package main
import "fmt"
func main() {
    queue := make(chan string, 2)
    queue <- "one"
    queue <- "two"
    close(queue)
    for elem := range queue {
        fmt.Println(elem)
    }
}

返回值:

$ go run range-over-channels.go
one
two

这通常是上个例子的简化实现方式。和上个例子中一样,只有当同时满足通道为空和通道关闭两个条件时for循环才会结束。在这个例子中,我们在for循环接受通道中的值之前,已经将通道关闭了,但是这并不会影响我们通过for和range继续循环的从通道中取值。

例6:Golang中互斥锁的使用

package main
import (
    "fmt"
    "sync"
)
func incrementCounter(counter *int, wg *sync.WaitGroup, mu *sync.Mutex) {
    defer wg.Done()
    //<-ch // 等待通道释放信号,表示可以执行增加操作
	for i := 0; i < 10000000; i++{
		mu.Lock()
        *counter++;
		mu.Unlock()
	}
}
func main() {
    numWorkers := 10
    counter := 0
    var wg sync.WaitGroup
	var mu sync.Mutex
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go incrementCounter(&counter, &wg, &mu)
    }
	wg.Wait() // 等待所有 Goroutine 完成
    fmt.Println("Final counter value:", counter)
}

返回值:

Final counter value: 100000000

在这个例子中我们通过一个互斥锁来保护counter,保证每次只有一个协程可以持有锁并对counter进行修改。当然,在实际工作中如果这么做将会是非常低效的。因为每次只有一个协程可以对值进行修改,约等于没有并发。在这里只是作为一个例子进行演示。

总结:

在Golang中,我们使用协程来取代更加笨重的线程来实现并发。管道的使用可以让我们在实现并发程序时更加快捷。在实际工作中使用管道而不是繁琐的互斥锁来实现协程之间的通信通常是更好的选择。