语言进阶
并发VS并行
并发和并行是计算机领域中两个重要的概念,它们描述了程序执行的方式和效果。
并发(Concurrency)
指的是同时处理多个任务的能力。在并发模型中,任务之间可以交替执行,每个任务都有可能在任意时刻被中断或暂停,然后切换到另一个任务。并发的关键在于任务的调度和切换,通过合理的调度算法,可以让多个任务看起来是同时在执行,提高系统的吞吐量和响应性。并发常用于处理I/O密集型的任务,例如网络请求、文件读写等。
并行(Parallelism)
指的是同时执行多个任务的能力。在并行模型中,任务真正地同时执行在多个处理器或多核系统上,每个任务都有独立的执行流。并行的关键在于硬件资源的并行处理能力,通过利用多个处理器或多核系统,可以实现真正的并行计算。并行常用于处理计算密集型的任务,例如图像处理、科学计算等。
Go语言是一种开源的编程语言,它具有并发编程的内置支持,可以轻松地编写并发和并行程序。Go语言的并发模型基于Goroutine和Channel。
Go语言的并发模型具有以下优势:
-
简单易用:Go语言提供了简洁的并发编程模型,Goroutine和Channel的使用非常简单,开发者可以轻松地编写并发程序。
-
高效性能:Go语言的Goroutine是轻量级的线程,创建和销毁的开销很小,可以高效地支持大量的并发执行。此外,Go语言的调度器具有自动抢占式调度特性,可以在多个Goroutine之间自动切换,提高程序的并发性能。
-
安全性:Go语言通过Channel提供了一种安全的数据传递和同步机制,避免了并发访问共享数据时的竞态条件和死锁问题。开发者可以使用Channel来实现Goroutine之间的协作和同步,编写出更加健壮和安全的并发程序。
-
扩展性:Go语言的并发模型可以很好地利用多核处理器和多核系统的并行计算能力。通过并行执行多个Goroutine,可以充分利用硬件资源,提高程序的处理能力和响应速度。
Go语言通过Goroutine和Channel提供了简单、高效、安全和可扩展的并发编程模型,使开发者能够轻松地编写出高性能的并发和并行程序。这使得Go语言在处理并发任务和构建高性能服务器等领域具有显著的优势。
Goroutine
在传统的操作系统中,线程(Thread)
是执行程序的最小单位。每个线程都有自己的执行环境(寄存器、栈等),并且由操作系统进行调度和管理。线程之间的切换需要操作系统的介入,这会引入一定的开销。
与线程相比,协程(Coroutine)
是一种更加轻量级的执行单位。协程是由程序员控制的,可以在代码层面进行调度和切换。协程的切换不需要操作系统的介入,因此开销更小。
Goroutine是Go语言中的协程实现,它是一种轻量级的线程。与传统的线程相比,Goroutine具有以下特点:
-
轻量级:Goroutine的创建和销毁的开销非常小,可以高效地创建大量的Goroutine。相比之下,传统线程的创建和销毁开销较大。
-
高效调度:Go语言的运行时调度器(Scheduler)负责管理和调度Goroutine的执行。调度器使用了一种称为M:N调度的策略,即将M个Goroutine调度到N个操作系统线程上执行。这种调度方式可以在少量的操作系统线程上高效地调度大量的Goroutine。
-
并发性:Goroutine可以并发地执行,多个Goroutine之间可以同时运行。在多核处理器上,多个Goroutine可以真正地并行执行,充分利用硬件资源。与传统的线程相比,Goroutine的并发性能更好。
-
通信和同步:Goroutine之间通过Channel进行通信和同步。Channel提供了一种安全的数据传递和共享状态的机制,避免了竞态条件和死锁等并发编程中常见的问题。
通过使用Goroutine,开发者可以轻松地编写出高效、安全和可扩展的并发程序。Goroutine的设计使得并发编程变得更加简单,开发者可以将复杂的并发逻辑拆分成多个独立的Goroutine,并通过Channel进行协作和通信,实现高效的并发处理。
注意,Goroutine并不是真正意义上的线程,它是由Go运行时环境管理的一种抽象概念。Go语言的运行时环境负责调度和管理Goroutine的执行,开发者无需关注底层的线程管理和调度细节,可以专注于编写并发逻辑。
CSP(Communicating Sequential Processes)
CSP(Communicating Sequential Processes)
是一种并发编程模型,它在Go语言中得到了广泛应用。CSP模型由计算机科学家Tony Hoare在1978年提出,它强调通过通信来进行并发处理,而不是共享内存。Go语言中的CSP模型主要通过Goroutine
和Channel
来实现。
在CSP模型中,系统中的并发处理单元被抽象为顺序执行的进程(Process)
,每个进程都有自己的执行流程。不同进程之间通过Channel
进行通信,Channel
是一种类型安全的、阻塞的、同步的数据结构。进程可以通过Channel
发送数据给其他进程,也可以通过Channel
接收其他进程发送的数据。通过Channel
的发送和接收操作,进程之间可以进行数据的传递和同步。
在Go语言中,Goroutine
是轻量级的执行单元,可以看作是一种特殊的进程。每个Goroutine
都有自己的执行流程,可以独立运行。Goroutine
之间通过Channel
进行通信和同步,实现数据的传递和共享状态的同步。
在Go语言中,可以使用内置的make函数创建一个Channel
,例如:
go
ch := make(chan int)
上述代码创建了一个传递整数类型数据的Channel
。通过Channel
的发送操作和接收操作,可以在不同的Goroutine
之间进行数据传递。发送操作使用<-运算符,接收操作使用<-运算符,例如:
go
ch <- 10 // 发送数据到Channel
x := <-ch // 从Channel接收数据
在CSP模型中,Channel
的发送和接收操作都是阻塞的。当发送操作执行时,如果Channel
已满(缓冲区已满),发送操作将阻塞,直到有其他Goroutine
从Channel
中接收数据。当接收操作执行时,如果Channel
为空(缓冲区为空),接收操作将阻塞,直到有其他Goroutine
向Channel
发送数据。
通过使用Goroutine
和Channel
,可以实现并发程序中的通信和同步。开发者可以将复杂的并发逻辑拆分成多个独立的Goroutine
,通过Channel
进行数据传递和共享状态的同步,实现高效、安全和可扩展的并发处理。
CSP模型的优点在于明确的通信和同步机制,避免了共享内存带来的竞态条件和死锁问题。通过使用Channel
进行通信,不同的Goroutine
之间可以实现松耦合的协作,提高程序的可读性和可维护性。
Go语言中的CSP模型通过Goroutine
和Channel
提供了一种简单、高效、安全的并发编程模型。开发者可以利用CSP模型编写出高性能、可靠的并发程序,充分发挥多核处理器和多核系统的并行计算能力。
Channel
当谈到Go语言中的并发编程和CSP模型时,Channel
是其中最重要的概念之一。Channel
是一种类型安全的、阻塞的、同步的数据结构,用于在Goroutine
之间进行通信和同步。它是实现Go语言中并发编程的核心机制之一,使得不同的Goroutine
可以安全地交换数据,实现数据传递和共享状态的同步。
在Go语言中,可以使用内置的make
函数来创建一个Channel,语法如下:
go
ch := make(chan T)
这里的T
表示Channel
中传递的数据的类型。例如,如果要创建一个传递整数类型数据的Channel,可以这样做:
go
ch := make(chan int)
Channel
支持两种基本操作:发送(send)和接收(receive)。发送操作用于将数据发送到Channel
中,接收操作用于从Channel
中接收数据。发送操作和接收操作都使用箭头符号(<-)。
发送操作:
go
ch <- data // 将数据data发送到Channel ch
接收操作:
go
result := <-ch // 从Channel ch接收数据,并将数据赋值给result变量
需要注意的是,发送操作和接收操作都是阻塞的。当发送操作执行时,如果Channel
已满(缓冲区已满),发送操作将阻塞,直到有其他Goroutine
从Channel
中接收数据。当接收操作执行时,如果Channel
为空(缓冲区为空),接收操作将阻塞,直到有其他Goroutine
向Channel
发送数据。
在默认情况下,Channel
是无缓冲的,这意味着发送操作和接收操作将会进行同步,发送操作会等待接收者接收数据,直到数据被接收,发送操作才会完成。类似地,接收操作会等待发送者发送数据,直到数据被发送,接收操作才会完成。
此外,还可以创建带有缓冲区的Channel
,通过在make
函数中传递第二个参数来指定缓冲区的大小,例如:
go
ch := make(chan int, 10) // 创建一个带有缓冲区大小为10的整数类型Channel
带有缓冲区的Channel
允许在缓冲区未满时进行发送操作,或者在缓冲区非空时进行接收操作,只有在缓冲区已满时的发送操作和在缓冲区为空时的接收操作才会阻塞。
Channel
的关闭操作用于通知接收者Channel
已经没有更多的数据发送。关闭Channel
后,接收者仍然可以继续从Channel
中接收已经发送的数据,直到所有数据都被接收完毕。关闭Channel
后,再进行发送操作会导致panic。
关闭Channel:
go
close(ch)
Channel
的关闭是一种广播机制,即所有的接收者都能够收到关闭的通知。因此,关闭一个已经关闭的Channel
会导致panic。
Channel
在Go语言中是并发安全的,多个Goroutine
可以同时对一个Channel
进行发送和接收操作而不会造成数据竞态(data race)等并发问题。这使得Channel
成为一种非常有用的数据传递和共享状态的机制。
Channel
是一种强大的并发编程工具,它通过提供阻塞的、同步的数据传递机制,实现了Goroutine
之间的通信和同步。Channel
的使用使得并发编程变得简单而安全,有助于开发高效、可靠的并发程序。
并发安全Lock
在Go语言中,为了保证多个Goroutine
并发访问共享资源时的数据安全性,我们可以使用并发安全的锁机制。锁是一种同步原语,它允许多个Goroutine
按顺序访问共享资源,防止多个Goroutine
同时修改数据而导致的竞态条件(race condition)和数据不一致性问题。
Go语言中提供了两种常用的锁:互斥锁(Mutex
)和读写锁(RWMutex
)。
- 互斥锁(Mutex): 互斥锁是最简单的锁类型,它在同一时刻只允许一个
Goroutine
访问共享资源。当一个Goroutine
获得了互斥锁的锁定(Lock
)后,其他尝试获得锁的Goroutine
将被阻塞,直到持有锁的Goroutine释放锁(Unlock
)为止。
互斥锁的使用示例:
go
package main
import (
"fmt"
"sync"
)
var counter int
var mutex sync.Mutex
func increment() {
mutex.Lock()
counter++
mutex.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
}
在上述示例中,我们使用互斥锁(sync.Mutex
)来保护共享变量counter
的访问。通过mutex.Lock()
和mutex.Unlock()
来分别锁定和解锁互斥锁。这样,多个Goroutine
并发执行increment
函数时,只有一个Goroutine
可以访问counter
,其他Goroutine
会被阻塞,直到锁被释放。
- 读写锁(RWMutex): 读写锁是互斥锁的一种改进,它允许多个
Goroutine
同时读取共享资源,但在写入时仍然需要互斥。这种情况下,多个Goroutine
可以同时获取读锁,但只有当没有任何读锁或写锁时,才能获取写锁。
读写锁的使用示例:
go
package main
import (
"fmt"
"sync"
)
var counter int
var rwLock sync.RWMutex
func read() {
rwLock.RLock()
fmt.Println("Counter:", counter)
rwLock.RUnlock()
}
func write() {
rwLock.Lock()
counter++
rwLock.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
read()
}()
}
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
write()
}()
}
wg.Wait()
}
在上面的代码中,我们使用读写锁(sync.RWMutex
)来保护共享变量counter
的读写访问。通过rwLock.RLock()
和rwLock.RUnlock()
来分别获取和释放读锁,通过rwLock.Lock()
和rwLock.Unlock()
来分别获取和释放写锁。
无锁和有锁的区别和最终影响:
-
无锁: 在没有锁的情况下,多个
Goroutine
可以同时访问共享资源。这种情况下,由于没有互斥机制,可能会发生竞态条件(race condition),导致数据不一致性和错误结果。 -
有锁: 使用锁可以保证共享资源的安全访问,防止多个
Goroutine
同时修改共享资源。在使用锁的情况下,多个Goroutine
会按顺序获取锁,并且只有持有锁的Goroutine
可以修改共享资源,其他Goroutine
会被阻塞。这样可以避免竞态条件,确保数据的一致性和正确性。
需要注意的是,过度使用锁可能会导致性能下降,因为锁会限制并发性 。因此,在设计并发程序时,需要权衡使用锁的频率和范围,以提高程序的并发性能。有时候可以使用更细粒度的锁来减少锁的竞争,或者使用无锁的数据结构来避免锁的开销。在实际应用中,对于不同的场景,选择合适的锁策略是一个需要仔细考虑的问题。
WaitGroup
在Go语言中,sync.WaitGroup
是一种用于等待一组Goroutine
完成执行的同步原语。它提供了一个简单的方法来等待多个Goroutine
的结束,以便在它们全部完成后再继续执行主线程或其他操作。WaitGroup
内部维护一个计数器,可以通过调用Add
方法来增加计数器的值,通过调用Done
方法来减少计数器的值,通过调用Wait
方法来阻塞直到计数器的值为零。
WaitGroup
的常用方法有三个:
-
Add(delta int): 增加计数器的值,
delta
可以为正数或负数。在启动一个新的Goroutine
之前,通过调用Add(1)
来增加计数器的值。当Goroutine完成时,通过调用Add(-1)
来减少计数器的值。 -
Done(): 减少计数器的值,相当于
Add(-1)
。 -
Wait(): 阻塞调用该方法的
Goroutine
,直到计数器的值变为零。一般在主线程或其他Goroutine
中调用Wait()
方法来等待所有增加的计数器都被减少到零,从而确保所有的Goroutine
都已经完成执行。
下面通过一段简单的代码示例来演示sync.WaitGroup
的使用:
go
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d started\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d finished\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("All workers have finished")
}
在上述示例中,我们定义了一个worker
函数来模拟一个需要执行一段时间的任务。在main
函数中,我们启动了5个Goroutine
来执行这个任务,并使用sync.WaitGroup
来等待所有的Goroutine
完成。在每个Goroutine
启动前,我们通过wg.Add(1)
来增加计数器的值,表示有一个Goroutine
需要等待。在worker
函数中,通过defer wg.Done()
来在Goroutine
执行完成后减少计数器的值。最后,我们在主线程中调用wg.Wait()
来等待所有的Goroutine
执行完成。
运行上述代码,你会看到类似如下的输出:
css
Worker 2 started
Worker 4 started
Worker 3 started
Worker 5 started
Worker 1 started
Worker 1 finished
Worker 5 finished
Worker 2 finished
Worker 3 finished
Worker 4 finished
All workers have finished
可以看到,所有的Goroutine
都顺利执行完成,并且在所有Goroutine
执行完成后,主线程继续执行,打印了"All workers have finished"的信息。
使用sync.WaitGroup
,我们可以在并发程序中很方便地等待多个Goroutine
完成执行,确保在它们全部完成后再继续执行后续操作。这在需要等待多个Goroutine
完成后进行结果汇总或其他后续处理的情况下非常有用。