在编写并发程序时,Golang的goroutine和channel确实让并发编程变得简单。但如果不注意默认的并发行为,很容易引入一些难以察觉的bug。今天我们就来聊聊Golang中那些因为默认并发行为导致的典型问题,以及如何避免它们。

一、为什么默认并发会出问题

Golang的并发模型很优雅,goroutine的启动成本极低,一个go关键字就能轻松创建并发任务。但正是这种"过于简单"的特性,让很多开发者忽略了并发安全的问题。

比如下面这个很常见的例子:

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 5; i++ {
		go func() {
			fmt.Println(i) // 这里会输出什么?
		}()
	}
	time.Sleep(time.Second) // 等待所有goroutine执行
}

你以为会输出0到4?实际运行结果可能是5、5、5、5、5。这是因为goroutine启动需要时间,等它们真正执行时,循环已经结束,i的值已经变成了5。

二、常见的默认并发陷阱

1. 循环变量捕获问题

上面那个例子就是典型的循环变量捕获问题。在Golang中,循环变量是被所有迭代共享的。正确的做法应该是:

for i := 0; i < 5; i++ {
    go func(i int) {  // 通过参数传递当前值
        fmt.Println(i)
    }(i)  // 传入当前i的值
}

2. 共享变量未加锁

var counter int

func increment() {
    counter++
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
    time.Sleep(time.Second)
    fmt.Println(counter) // 结果很可能不是1000
}

这个例子展示了经典的竞态条件问题。counter++看似是一个原子操作,实际上包含了读取、增加、写入三个步骤。正确的做法是使用sync.Mutex:

var (
    counter int
    mutex   sync.Mutex
)

func increment() {
    mutex.Lock()
    defer mutex.Unlock()
    counter++
}

3. Channel使用不当

ch := make(chan int)
go func() {
    ch <- 1
}()
close(ch)  // 过早关闭channel
val := <-ch // 可能panic

正确的做法是应该由发送方关闭channel,并且要确保所有发送操作完成后再关闭。

三、如何避免并发bug

1. 使用sync包的工具

Golang的标准库提供了很多并发安全的工具:

var wg sync.WaitGroup

func worker(id int) {
    defer wg.Done() // 任务完成时通知WaitGroup
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    for i := 1; i <= 5; i++ {
        wg.Add(1) // 增加等待计数
        go worker(i)
    }
    wg.Wait() // 等待所有worker完成
}

2. 使用context管理goroutine生命周期

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保在main结束时取消所有goroutine

go func(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("goroutine收到取消信号")
        return
    case <-time.After(2 * time.Second):
        fmt.Println("工作完成")
    }
}(ctx)

3. 使用原子操作

对于简单的计数器,可以使用atomic包:

var counter int64

func increment() {
    atomic.AddInt64(&counter, 1)
}

四、实战案例分析

让我们看一个更复杂的例子:实现一个并发的网络爬虫。这个例子会展示如何正确管理并发、避免资源竞争。

package main

import (
	"fmt"
	"net/http"
	"sync"
)

type Crawler struct {
	visited map[string]bool
	mu      sync.Mutex
	wg      sync.WaitGroup
}

func (c *Crawler) visit(url string) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	
	if c.visited[url] {
		return false
	}
	c.visited[url] = true
	return true
}

func (c *Crawler) crawl(url string, depth int) {
	defer c.wg.Done()
	
	if depth <= 0 {
		return
	}
	
	if !c.visit(url) {
		return
	}
	
	fmt.Printf("正在抓取: %s\n", url)
	resp, err := http.Get(url)
	if err != nil {
		fmt.Printf("抓取 %s 失败: %v\n", url, err)
		return
	}
	defer resp.Body.Close()
	
	// 这里应该解析页面获取更多链接
	// 为了示例简单,我们假设找到了一些新链接
	newLinks := []string{
		"https://example.com/page1",
		"https://example.com/page2",
	}
	
	for _, link := range newLinks {
		c.wg.Add(1)
		go c.crawl(link, depth-1)
	}
}

func main() {
	crawler := &Crawler{
		visited: make(map[string]bool),
	}
	
	crawler.wg.Add(1)
	go crawler.crawl("https://example.com", 2)
	crawler.wg.Wait()
	
	fmt.Println("抓取完成")
}

这个例子展示了如何正确使用WaitGroup、Mutex来管理并发爬虫。注意以下几点:

  1. 使用互斥锁保护共享的visited map
  2. 使用WaitGroup等待所有goroutine完成
  3. 控制爬取深度避免无限递归

五、性能与安全的权衡

并发编程永远是在性能和安全性之间寻找平衡点。过度使用锁会导致性能下降,而完全不使用锁又会导致数据竞争。

一些优化建议:

  1. 尽量缩小临界区范围
  2. 考虑使用读写锁(sync.RWMutex)替代普通互斥锁
  3. 对于读多写少的场景,考虑使用copy-on-write模式
  4. 合理设置goroutine池大小,避免创建过多goroutine

六、总结

Golang的并发模型虽然简单易用,但默认行为中隐藏着不少陷阱。通过本文的分析,我们了解了:

  1. 循环变量在goroutine中的共享问题
  2. 共享数据需要适当的同步机制
  3. channel的正确使用方式
  4. 使用标准库提供的并发工具
  5. 在实际项目中如何平衡性能与安全

记住,并发程序的bug往往难以复现,但一旦发生就可能造成严重后果。养成良好的并发编程习惯,才能写出既高效又可靠的Go程序。