【golang】在单线程程序中,协程并发是安全的吗?

前言

我们知道,在多线程程序中,如果多个线程操作同一个共享变量,在不添加特殊处理代码(比如加锁)的情况下,是会有线程安全问题的。但是,如果换成是“多个协程操作同一个变量”呢?还会有安全问题吗?

实验环境

Windows 11
Go 1.20.2

过程

先看一段Golang代码示例:

func main() {
	count := 0

	wg := sync.WaitGroup{}
	wg.Add(2)
	// 协程1
	go func() {
		for i := 0; i < 100000; i++ {
			count++
		}
		wg.Done()
	}()
	// 协程2
	go func() {
		for i := 0; i < 100000; i++ {
			count++
		}
		wg.Done()
	}()

	wg.Wait() // 等待上面两个协程都执行完了,再往下执行
	fmt.Println(count)
}

上面代码中,协程1 和 协程2 共同对 count 变量执行 +1 操作,各自循环10万遍。

理论上最后输出count的值应该等于20万,但实际输出的值远小于20万。比如我的输出结果是116346

为什么会这样呢,首先是因为 count++ 不是一个原子性的操作,它实际是由三句代码组成的,等同于:

tmp :=  count
tmp = tmp + 1
count = tmp

是一个“先查询后更新”的操作。

然后,Go语言默认情况下是多线程的,线程数量默认等于CPU的核心数。比如双核CPU,Go就会开启两个线程来运行协程,协程1线程A上执行,协程2线程B上执行,由于是多核CPU,这两个线程是可以并行执行的。因此归根到底,这实际上是一个线程安全的问题,即多个线程操作同一个变量导致的。

疑问1

既然是多线程的原因,那如果改成单线程,是不是就不会有问题了呢?

Go语言刚好有提供这样的配置:runtime.GOMAXPROCS(N),其中N就是你想要设置的线程数量。想改为单线程那么只要将N设置为 1 就好。

更改后的代码例子:

func main() {
	runtime.GOMAXPROCS(1) // 设置为单线程
	count := 0

	wg := sync.WaitGroup{}
	wg.Add(2)
	// 协程1
	go func() {
		for i := 0; i < 100000; i++ {
			count++
		}
		wg.Done()
	}()
	// 协程2
	go func() {
		for i := 0; i < 100000; i++ {
			count++
		}
		wg.Done()
	}()

	wg.Wait() // 等待上面两个协程都执行完了,再往下执行
	fmt.Println(count) // 输出:200000
}

果然改为单线程后,就不存在“线程安全”的问题了,能正确输出count的值了。

注:在实际生产环境中,不建议通过设置为单线程模式来避免此类问题,因为单线程会降低Go执行协程的效率,可以通过其它方式,比如加锁、channel之类的方式来解决。

疑问2

改为单线程后,就可以毫无顾虑的使用多协程了吗?答:并不是。

在某些场景下如果不注意还是会有隐患的,比如这段代码:

func main() {
	runtime.GOMAXPROCS(1) // 设置为单线程
	count := 0

	wg := sync.WaitGroup{}
	wg.Add(2)
	// 协程1
	go func() {
		for i := 0; i < 100000; i++ {
			count++
		}
		wg.Done()
	}()
	// 协程2
	go func() {
		fmt.Printf("我是协程2,此时count = %d\n", count)
		for i := 0; i < 100000; i++ {
			tmp := count
			tmp = tmp + 1
			count = tmp
		}
		time.Sleep(time.Second * 2) // 此处会迫使协程2让出执行权
		fmt.Printf("我是协程2,已经执行了10万次 +1 操作,此时count = %d\n", count)
		wg.Done()
	}()

	wg.Wait() // 等待上面两个协程都执行完了,再往下执行
}

上述代码会输出:

我是协程2,此时count = 0
我是协程2,已经执行了10万次 +1 操作,此时count = 200000

协程2 一开始查询到的count值是0,执行了10万次+1后,count值居然变成了20万。

这是因为睡眠语句time.Sleep(time.Second * 2)会迫使协程2让出CPU的使用权,让出CPU使用权后,当前线程会改为去执行协程1,当协程1执行完后或遇到 IO阻塞 时,才会又切回来执行协程2,但切回来后,此时的count值已经被协程1修改过了,所以肯定跟协程2刚开始时查询到的值是不一样的。

除了sleep语句外,当协程遇到IO阻塞时,也会让出CPU使用权

所以在协程执行过程中,如果我们想要确保全局变量不会被其它协程修改,就要给变量加锁。

并发安全

其实,“线程安全”这种说法容易让人产生误解,以为只有在多线程环境中才会有安全问题。但其实不管是多进程、多线程还是多协程,只要有并发场景存在,并且产生了竞态,就会有安全问题,所以更准确的说法应该是“并发安全”。

几个并发安全的例子

1、多个协程给同一个变量+1(使用通道方法)

因为golang的通道是并发安全的,所以我们可以利用通道来实现

func main() {
	count := 0
	ch := make(chan int)

	wgWrite := sync.WaitGroup{}
	wgWrite.Add(2)
	wgRead := sync.WaitGroup{}
	wgRead.Add(2)
	// 协程1:负责通道写入
	go func() {
		defer wgWrite.Done()
		for i := 0; i < 100000; i++ {
			ch <- 1
		}
	}()
	// 协程2:负责通道写入
	go func() {
		defer wgWrite.Done()
		for i := 0; i < 100000; i++ {
			ch <- 1
		}
	}()
	// 协程3:负责读取ch通道,并将值累加到count变量
	go func() {
		defer wgRead.Done()
		for val := range ch {
			count += val
		}
	}()
	// 协程4:负责等待协程1、协程2执行完毕
	go func() {
		defer wgRead.Done()
		wgWrite.Wait()
		// 执行完毕后,要关闭ch通道,不然协程3会阻塞在读取通道处
		close(ch)
	}()

	wgRead.Wait() // 等待协程3、协程4执行完毕
	fmt.Println(count)
}
2、多个协程给同一个变量+1(使用互斥锁方法)

使用互斥锁,相当于牺牲了协程的并发性能,改为串行执行,因为只有当一个协程释放了锁,另外一个协程才能获得锁去执行代码

func main() {
	count := 0
	mutex := sync.Mutex{}

	wgWrite := sync.WaitGroup{}
	wgWrite.Add(2)
	// 协程1
	go func() {
		defer wgWrite.Done()
		defer mutex.Unlock()
		mutex.Lock()
		for i := 0; i < 100000; i++ {
			count++
		}
	}()
	// 协程2
	go func() {
		defer wgWrite.Done()
		defer mutex.Unlock()
		mutex.Lock()
		for i := 0; i < 100000; i++ {
			count++
		}
	}()

	wgWrite.Wait() // 等待两个协程执行完毕
	fmt.Println(count)
}

参阅

<think>好的,我现在需要回答用户关于Go语言及其协程的问题。用户之前询问过RPC和HTTP的问题,现在转向Go语言,可能是在构建分布式系统或微服务,因此需要了解Go的并发特性。 首先,我需要确认自己对Go语言的了解是否全面。Go的协程(goroutine)是其并发模型的核心,还有通道(channel)用于协程间通信。用户可能想知道协程的优势、工作原理以及与其他语言线程的区别。 接下来,用户可能关心协程的实际应用场景,比如如何处理高并发协程调度机制,以及如何避免常见问题,比如竞态条件或死锁。需要解释GMP模型,即Goroutine、M(机器线程)、P(处理器)的关系,说明Go运行时如何高效管理协程。 还要考虑用户可能的疑问,比如协程的资源消耗,为什么比线程轻量,以及如何通过同步机制如WaitGroup或Mutex来管理协程。需要举例说明,比如并发处理HTTP请求,或者并行计算。 此外,用户可能对协程的错误处理感兴趣,比如如何捕获协程中的panic,或者如何优雅地关闭协程。需要提及recover函数和context包的使用。 最后,确保回答结构清晰,分点说明,包括基本概念、底层原理、使用示例和注意事项。使用代码示例时,用简单的例子展示协程的启动和通信,比如计算斐波那契数列或并发获取多个API数据。 需要避免过于技术化的术语,用通俗的语言解释,同时保持准确性。可能还需要对比其他语言的并发模型,比如Java的线程与Go的协程,突出Go的优势,如内存占用低、创建成本小、调度效率高等。 总结回答的结构:先介绍Go语言及其特点,然后重点讲解协程的定义、优势、工作原理,接着是使用示例和最佳实践,最后常见问题及解决方案。确保覆盖用户可能关心的各个方面,帮助用户全面理解Go协程的应用和原理。</think>以下是关于Go语言及其协程机制的详细说明: --- ### 一、Go语言核心特点 1. **C系血统** - 语法类似C,但去除指针运算、类继承等复杂特性 - 编译型语言,直接生成机器码(编译速度极快) 2. **并发原生支持** - 协程(goroutine)为语言级并发单元 - 通道(channel)实现CSP通信模型 3. **内存管理** - 自动垃圾回收(GC) - 无手动内存管理需求 4. **工具链完善** - 内置格式化工具(go fmt) - 依赖管理(go mod) - 测试框架(go test) --- ### 二、Go协程(goroutine)深度解析 #### 1. 基本特性 ```go func main() { go func() { // 使用go关键字启动协程 fmt.Println("goroutine执行") }() time.Sleep(100 * time.Millisecond) // 等待协程完成 } ``` - **轻量级**:初始栈仅2KB(线程通常MB级) - **低成本**:创建百万级协程无压力 - **快速切换**:用户态调度,无内核切换开销 #### 2. 调度模型(GMP) ![GMP模型示意图](https://example.com/gmp-model.png)*(图示:Go调度器工作原理)* - **G**(Goroutine):协程实体 - **M**(Machine):操作系统线程 - **P**(Processor):逻辑处理器(默认等于CPU核心数) | 组件 | 职责 | 数量关系 | |------|-----|---------| | G | 执行单元 | 动态变化 | | M | 系统线程 | 默认<=10000 | | P | 资源调度 | 默认=CPU核心数 | #### 3. 调度策略 - **工作窃取**(Work Stealing):空闲P从其他P队列偷G - **抢占式调度**:10ms强制切换(防止协程独占) - **系统调用优化**:分离网络轮询器(netpoller) #### 4. 并发控制 ```go var wg sync.WaitGroup func worker() { defer wg.Done() // 业务逻辑 } func main() { wg.Add(10) for i := 0; i < 10; i++ { go worker() } wg.Wait() // 等待所有协程完成 } ``` --- ### 三、协程 vs 线程对比 | 维度 | Go协程 | 系统线程 | |---------------|-------------------|------------------| | 创建成本 | 2KB初始内存 | 1MB以上 | | 切换开销 | 用户态200ns | 内核态1-2μs | | 调度方式 | Go运行时协作调度 | OS内核抢占调度 | | 并发规模 | 轻松百万级 | 通常数千级 | | 内存占用 | 动态伸缩(max1GB)| 固定栈大小 | --- ### 四、最佳实践与注意事项 1. **通道使用原则** - 优先使用`chan struct{}`作为信号通道 - 带缓冲通道不是队列,慎用`cap(chan) > 0` 2. **资源竞争防范** ```go var counter int var mu sync.Mutex func safeIncrement() { mu.Lock() defer mu.Unlock() counter++ } ``` 3. **协程泄漏检测** - 使用`runtime.NumGoroutine()`监控协程数量 - 确保所有协程都有退出路径 4. **性能调优技巧** - 设置`GOMAXPROCS`(通常无需调整) - 避免在热路径中使用`defer` - 使用`sync.Pool`减少内存分配 --- ### 五、典型应用场景 1. **高并发服务** ```go func handleConn(conn net.Conn) { defer conn.Close() // 处理连接 } func main() { ln, _ := net.Listen("tcp", ":8080") for { conn, _ := ln.Accept() go handleConn(conn) // 每个连接独立协程 } } ``` 2. **并行计算** ```go func parallelProcess(data []int) { ch := make(chan result) for _, d := range data { go func(v int) { ch <- compute(v) // 并行计算 }(d) } // 收集结果... } ``` 3. **定时任务调度** ```go func cronJob() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case <-ticker.C: doTask() } } } ``` --- Go语言的协程机制彻底改变了并发编程范式,使得开发者可以用同步的方式编写异步代码,极大降低了并发编程的心智负担。其设计在云计算、微服务、分布式系统等领域展现出显著优势,成为现代后端开发的利器。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值