Go

Go 知识量:6 - 35 - 115

1.5 面向并发的内存模型><

Goroutine和系统线程- 1.5.1 -

Go语言中的Goroutine是轻量级的线程,它是由Go运行时(runtime)系统管理的并发执行单元。与系统线程相比,Goroutine更加轻量级,可以快速创建和销毁,适合用于实现并发编程和高并发场景。

系统线程是操作系统级别的线程,由操作系统调度和管理。每个系统线程都有独立的线程上下文和栈空间,拥有自己的CPU寄存器和信号掩码等资源。系统线程的创建、切换和销毁都需要一定的开销,因此不适合用于实现大量的并发执行单元。

相比之下,Goroutine的设计目标是提供一种更加高效的方式来处理并发执行。在Go语言中,通过关键字go可以轻松启动一个Goroutine,从而在并发执行的情况下进行通信和同步。Goroutine的调度由Go运行时系统负责,它可以快速地在不同的Goroutine之间切换,从而实现高并发处理。

需要注意的是,虽然Goroutine是轻量级的并发执行单元,但是它并不是真正的并行执行。在单核CPU上,Goroutine仍然是顺序执行的,一次只能有一个Goroutine在运行。但是在多核CPU或多核机器上,Go运行时系统可以利用并行计算的能力来同时执行多个Goroutine。

原子操作- 1.5.2 -

在Go语言中,原子操作是用于在并发编程中确保操作原子性的机制。原子操作可以在并发环境中被多个goroutine同时访问时保证其完整性和一致性。

Go语言标准库中的sync/atomic包提供了原子操作的函数,包括对基本数据类型的原子操作,如AddInt32、AddInt64、CompareAndSwapInt32等。这些函数可以确保对整型、指针等基本数据类型的原子操作。

下面是一些常用的原子操作函数:

  • AddInt32(addr *int32, delta int32) int32: 将给定地址的int32值增加delta,并返回增加后的值。

  • AddInt64(addr *int64, delta int64) int64: 将给定地址的int64值增加delta,并返回增加后的值。

  • CompareAndSwapInt32(addr *int32, old, new int32) bool: 比较给定地址的int32值与old值,如果相等,则将该地址的值设置为new值,并返回true;否则不进行任何操作,并返回false。

  • CompareAndSwapInt64(addr *int64, old, new int64) bool: 比较给定地址的int64值与old值,如果相等,则将该地址的值设置为new值,并返回true;否则不进行任何操作,并返回false。

这些原子操作函数使用起来非常简单,只需要将要操作的变量作为参数传递给函数即可。下面是一个简单的示例:

package main  
  
import (  
 "fmt"  
 "sync/atomic"  
 "time"  
)  
  
func main() {  
 var counter int32  
  
 // 启动多个goroutine并发增加计数器  
 for i := 0; i < 100; i++ {  
 go func() {  
 atomic.AddInt32(&counter, 1) // 原子增加计数器  
 }()  
 }  
  
 // 等待一段时间,让goroutine有机会执行  
 time.Sleep(time.Second)  
  
 fmt.Println("Counter:", counter) // 输出最终的计数器值  
}

在上面的示例中,使用atomic.AddInt32函数对counter变量进行原子增加操作。由于该操作是原子的,因此可以确保多个goroutine同时访问和修改counter变量时不会出现竞态条件。最终输出的计数器值应该为100,表示所有goroutine都成功地增加了计数器的值。

顺序一致性内存模型- 1.5.3 -

Go语言的顺序一致性内存模型是一种保证并发程序正确性的内存模型。在Go语言中,通过顺序一致性的内存模型,程序员可以更轻松地编写并发程序,而无需担心数据竞争和线程安全问题。

顺序一致性内存模型遵循以下规则:

  • 顺序一致性:在程序中,每个操作都有一个唯一的顺序,称为执行顺序。无论哪个线程观察到其他线程的执行顺序,都总是观察到相同的顺序。

  • 原子性:每个操作是不可分割的,即在一个操作执行完成之前,其他线程无法读取或修改该操作所涉及的内存位置。

  • 可见性:当一个线程写入一个变量后,其他线程可以立即读取该变量的最新值。

  • 有序性:指令重排序不会改变单线程程序的行为,也不会导致数据竞争。

为了实现这些规则,Go语言运行时系统采取了一些措施。首先,Go语言运行时系统对指令进行重新排序,以优化性能。但是,这种重新排序不会改变单线程程序的行为,也不会导致数据竞争。其次,Go语言运行时系统使用写屏障(write barrier)来确保写操作的可见性。当一个线程写入一个变量后,写屏障会确保其他线程能够立即读取该变量的最新值。最后,Go语言运行时系统还使用读写锁和其他同步机制来确保并发访问的安全性。

初始化顺序- 1.5.4 -

在Go语言中,程序的初始化顺序主要涉及到包级别变量的初始化、初始化函数(init函数)的调用以及结构体字段的初始化。以下是详细的初始化顺序:

1. 包级别变量的初始化:

  • 首先,Go程序会按照包级别声明的变量在源文件中的顺序进行初始化。这意味着在同一个包中,先声明的变量会先被初始化。

  • 所有的全局变量(包级别的变量)会在main函数执行前完成初始化。

2. 初始化函数(init函数)的调用:

  • Go语言允许在包中定义多个init函数。这些函数没有参数和返回值。

  • 所有的init函数会在包级别变量初始化之后、main函数执行之前被自动调用。

  • init函数按照在源文件中声明的顺序被调用。如果一个包导入了其他包,那么被导入包的init函数会在导入它的包的init函数之前被调用。

3. 结构体字段的初始化:

  • 结构体的每个字段都会按照它们在结构体定义中的顺序进行初始化。

  • 如果字段是复合类型(如另一个结构体、数组或切片),那么其内部字段或元素也会按照相应的顺序进行初始化。

4. 局部变量的初始化:

  • 局部变量(包括函数参数、循环变量等)会在函数被调用时进行初始化,并且每次函数被调用时都会重新初始化。

  • 局部变量在函数体内的声明顺序决定了它们的初始化顺序。

5. 映射的初始化:

  • Go语言的映射是引用类型,其初始化涉及到内存分配和键值对的插入。但是,映射的初始化顺序是不确定的,因为映射的内部实现是并发的。

Goroutine的创建- 1.5.5 -

在Go语言中,Goroutine是轻量级的线程,用于实现并发执行。Goroutine的创建非常简单,可以使用关键字go来启动一个新的Goroutine。

下面是一个简单的示例,演示了如何创建Goroutine:

package main  
  
import (  
 "fmt"  
 "time"  
)  
  
func main() {  
 // 启动一个Goroutine执行任务  
 go doTask()  
  
 // 主线程执行其他任务  
 fmt.Println("Main thread is running...")  
 time.Sleep(2 * time.Second)  
 fmt.Println("Main thread is done.")  
}  
  
func doTask() {  
 fmt.Println("Goroutine is running...")  
 time.Sleep(1 * time.Second)  
 fmt.Println("Goroutine is done.")  
}

在上面的示例中,使用go关键字启动了一个名为doTask的函数作为Goroutine执行。Goroutine和主线程可以并行运行,它们之间没有直接的关系。这意味着Goroutine的执行不受主线程的限制,可以独立地执行任务。

在示例中,通过调用time.Sleep函数使主线程休眠2秒,而Goroutine则会在这段时间内执行任务。输出结果将显示Goroutine和主线程的执行顺序。

请注意,Goroutine的创建和销毁都非常轻量级,因此可以创建大量的Goroutine来处理并发任务,而不会对系统造成太大的负担。但是,过多的Goroutine可能会导致上下文切换频繁,从而影响性能。因此,在使用Goroutine时,需要根据实际情况进行合理的规划和优化。

基于通道的通信- 1.5.6 -

在Go语言中,通道(channel)是一种用于协程(goroutine)之间进行通信和同步的机制。通过通道,协程可以发送和接收数据,实现并发执行中的信息传递。

要创建一个通道,可以使用内置的make函数,指定通道的元素类型。例如,要创建一个整数类型的通道,可以使用以下语法:

ch := make(chan int)

创建通道后,可以使用<-运算符发送和接收数据。发送操作使用<-运算符后跟要发送的值,接收操作使用<-运算符后跟通道变量。

下面是一个简单的示例,演示了如何使用通道进行通信:

package main  
  
import "fmt"  
  
func main() {  
    // 创建一个整数类型的通道  
    ch := make(chan int)  
  
    // 启动一个协程发送数据到通道  
    go func() {  
        ch <- 42 // 发送数据到通道  
    }()  
  
    // 从通道接收数据并打印  
    value := <-ch // 从通道接收数据  
    fmt.Println(value) // 输出:42  
}

在上面的示例中,创建了一个整数类型的通道ch,然后启动一个协程向通道发送数据。在主线程中,使用<-ch语法从通道接收数据,并将其赋值给变量value。最后,打印接收到的值。

通道还支持阻塞和非阻塞操作。默认情况下,发送和接收操作是阻塞的,即在发送或接收数据时,如果通道未准备好,操作会等待直到有数据可用或被关闭。如果要进行非阻塞操作,可以使用带有select语句的default分支。

以下是使用非阻塞操作的示例:

package main  
  
import "fmt"  
  
func main() {  
    // 创建一个整数类型的通道  
    ch := make(chan int)  
  
    // 启动一个协程发送数据到通道  
    go func() {  
        ch <- 42 // 发送数据到通道  
    }()  
  
    // 使用select语句进行非阻塞接收操作  
    select {  
    case value := <-ch: // 从通道接收数据  
        fmt.Println(value) // 输出:42  
    default: // 如果没有数据可用,执行default分支的代码  
        fmt.Println("No data available") // 输出:No data available  
    }  
}

在上面的示例中,使用select语句进行非阻塞的接收操作。如果通道中有数据可用,则执行接收操作并打印接收到的值;如果没有数据可用,则执行default分支的代码并打印相应的消息。