Mutex 是一种互斥锁, 名称来自 mutual exclusion , 是一种用于控制多线程对共享资源的竞争访问的同步机制. 当一个线程获取互斥锁时, 它将阻止其他线程对该资源的访问, 直到该线程释放锁. 这可以防止多个线程对共享资源进行冲突访问, 从而保证线程安全.

通常把 Mutex 这样的用来帮助实现同步的类型称为同步原语(synchronization primitve)

互斥锁的概念可以追溯到 1968 年, 当时计算机科学家 E.W.Dijkstra 在论文 “Solutions of a Problem in Concurrent Programming Control” 中首次实现了一种同步机制, 防止两个进程同时进入临界区(critical section), 该方案后来被称为 “Dijkstra 互斥算法”, 并成为互斥锁的一种基本实现.

在 Go 语言中, 标准库 sync 包中提供了 Mutex, 它实现了互斥锁的功能. Mutex 可以提供对临界区的保护. 临界区不仅仅指一个资源, 一个变量, 它也可以指一组资源, 一段处理代码, 我们把程序中这部分因为并发访问和修改的操作, 对一个 I/O 设备的使用, 对一个共享状态的修改, 对一组资源的原子访问和修改等.

Mutex 限定临界区同一时间只能有一个 goroutine 进入. 当临界区中有一个 goroutine 时, 如果其他 goroutine 想进入这个临界区, 就会返回失败, 或者需要等待, 直到已进入的那个 goroutine 退出临界区, 这些等待的 goroutine 中的某一个才有机会接着进入这个临界区.

竞争条件与数据竞争

竞争条件: 指的是在多线程环境中, 由于操作顺序的不确定性导致的程序执行结果的不确定性. 例如, 如果两个线程同时对同一个变量进行读/写操作, 那么它们的执行顺序将会对最终的结果产生影响. 这就是竞争条件.典型的示例包括上下文切换, 操作系统信号, 多处理器上的内存操作和硬件中断等.竞争条件有时候难以避免, 因为在很多情况下我们无法精确控制 goroutine 的运行顺序

数据竞争: 指的是在多线程环境中, 由于操作顺序的不确定性导致的数据不一致的问题. 例如, 两个线程同时对同一个变量进行读/写操作, 并且没有使用任何同步机制, 那么它们的操作将会导致数据不一致.

竞争条件和数据竞争之间既不是子集的关系, 也不是充分必要条件的关系. 竞争条件和数据竞争的区别在于: 前者是一种状态, 而后者是一种问题.

以银行转账为例子说明两者不同

type account struct {
	balance int64
	intx    bool
}

func transfer1(amount int64, accountfrom, accountto *account) bool {
	if accountfrom.balance < amount {
		return false
	}
	accountto.balance += amount
	accountfrom.balance -= amount
	return true
}

transfer1 函数既有竞争条件, 也有数据竞争的问题. 如源账户的余额有 100 万元, 如果同时有两个转账操作, 都从这个源账户转出 100 万元, 那么源账户的余额就可能变成 0 元, 而两个目的账户都增加了 100 万元, 银行莫名其妙地损失了 100 万元, 这是数据竞争的问题, 在不同的转账顺序下, 账户的余额可能还会不同, 这是由执行顺序导致的竞争条件问题

func transfer2(amount int64, accountfrom, accountto *account) bool {
	bal := atomic.LoadInt64(&accountfrom.balance)
	if bal < amount {
		return false
	}
	atomic.AddInt64(&accountto.balance, amount)
	atomic.AddInt64(&accountfrom.balance, -amount)
	return true
}

在多线程并发操作的情况下, transfer2 函数没有数据竞争问题, 对源账户的余额操作都是原子操作, 不会出现部分被修改的情况, 所以账户里的钱不会凭空消失. 但是这个函数存在竞争条件问题.

比如源账户的余额有 100 万元, 两个 goroutine 同时转账, 其他 g1 转账 50 万, g2 转账 70 万, 无法预测它们执行顺序, 如果 g1 先执行, 那么 g2 执行不成功. 最后源账户的余额只有 50 万, 如果 g2 先执行, 那么 g1 执行不成功, 最后源账户的余额还剩 30万. 执行顺序的不同导致结果不同, 这是竞争条件问题.如果 g1 和 g2 同时调用 transfer2, 都检查到源账户有 100 万, 都认为可以转账, 结果执行完转账后, 源账户的余额变 -20万

var txMutex sync.Mutex

func transfer3(amount int64, accountfrom, accountto *account) bool {
	txMutex.Lock()
	defer txMutex.Unlock()
	bal := atomic.LoadInt64(&accountfrom.balance)
	if bal < amount {
		return false
	}
	atomic.AddInt64(&accountto.balance, amount)
	atomic.AddInt64(&accountfrom.balance, -amount)
	return true
}

使用一个互斥锁 Mutex 来解决竞争条件问题, 其中转账那几行代码被称为临界区. 即使在多线程并发调用的情况下, tranfer3 也和我们期望的一样, 要么成功, 要么失败, 不会因为多线程执行顺序不同而得到不期望的结果: 用户的账户不会被透支, 保证在余额充足的情况可以转账.

Mutex 用法

因为并发编程中有竞争条件和数据竞争的问题, 才需要将代码片段设定为临界区, 通过使用 Mutex 等同步原语将临界区保护起来.

// counter = 17145350, want 6400000
func TestCounter(t *testing.T) {
	var counter int64

	var wg sync.WaitGroup

	for i := 0; i < 64; i++ {
		wg.Add(1)
		go func() {
			for i := 0; i < 1000000; i++ {
				counter++
			}
			wg.Done()
		}()
	}
	wg.Wait()

	if counter != 64000000 {
		t.Errorf("counter = %d, want 64000000", counter)
	}
}