高并发系统系列-使用lock & Interlocked CAS(compare and swap)

前言

在之前我有写一篇关於资料库的ACID分享RDBMS资料库基本原则

假如我们系统是一个多执行续高并发系统也要注意Atomic不然会造成资料会有Data Racing导致bug产生..

本文使用范例在我github上CAS_Lock有兴趣的人可以参考.

本篇同步发布在我的Blog 高并发系统系列-使用lock & Interlocked CAS(compare and swap)

没有注意Atomic会导致的问题

我是我们公司担任技术面试官之一,假如面试者说他实作过高并发系统

我就会先问以下问题来辨别是否要更深入面试高并发系统相关问题.

下面Sample Code是这样.

我用Task假装高并发,下面有一个Member类别预设给100000元的Balance

有一个UpdateBalance每呼叫一次就扣10元,我透过For跑10000次

理论上我预期在跑完下面显示Balance会员余额是0 (因为 10000*10 = 100000).

class Program
{
	static void Main(string[] args)
	{
		Member member = new Member() { Balance = 100000 };
		List<Task> tasks = new List<Task>();
		for (int i = 0; i < 10000; i++)
		{
			tasks.Add(Task.Run(() => member.UpdateBalance()));
		}

		Task.WaitAll(tasks.ToArray());

		Console.WriteLine($"member remaining balance is {member.Balance}");
		Console.ReadKey();
	}
}

public class Member {
	public decimal Balance { get; set; }

	public void UpdateBalance()
	{
		Balance -= 10;
	}
}

但执行後蛮常会是不等於0!!

这时我会问面试者两个问题

  • 为什麽会不等於0
  • 如果要解决你会怎麽解决这个问题?

如果知道的人可以跳到CAS部分,如果不知原因我下面会跟大家分享

为什麽会不等於0

这个问题牵扯到Thread是如何对於变数操作的,Thread在操作变数之前会把资料复制一份进Thread Context中在操作我们要步骤

所以在Balance -= 10;这段程序码会拆成下面动作

  1. 将执行个体变数中的值载入至register。
  2. 将载入值减10
  3. 将异动後值放回原本值的Memory。

因为以上步骤,假如同一时间有两个不同的Thread取到的Balance都是1000,并个别对於Balance减10元,我们原本预期这是两个操作(预期资料为980)

但因为取的瞬间都是1000-10=990把数值放回变数中就导致少扣一个10元...

概念如下图.

知道原因了要解决就简单了.

使用Lock解决

因为这段程序码遇到Data Raceing在同一个时间有两个不同的Thread对於资料竞争

如果要避免竞争lock是一个比较方便的方式,他可以保证一瞬间只有一个Thread(Session)来执行某段程序码(通常会放在异动资料那部分)来保证Isolation.

下面是使用lock版本的程序码,能看到我在Balance -= 10;这一段使用lock来确保每一个瞬间只有一个Thread可以异动资料,其他的Thread需要blocking等他完成

class Program
{
	static object _lock = new object();
	static void Main(string[] args)
	{
		Member member = new Member() { Balance = 100000 };
		List<Task> tasks = new List<Task>();
		for (int i = 0; i < 10000; i++)
		{
			tasks.Add(Task.Run(() => member.UpdateBalance()));
		}

		Task.WaitAll(tasks.ToArray());

		Console.WriteLine($"member remaining balance is {member.Balance}");
		Console.ReadKey();
	}
}

public class Member {
	//here
	object _lock = new object();

	public decimal Balance { get; set; }

	public void UpdateBalance()
	{
		lock (_lock)
		{
			Balance -= 10;
		}
	}
}

使用lock之後能发现不管执行几次资料都会如我们预期显示.

使用lock执行概念图如下.

在同一时间我们会把要执行程序 利用一个类似保护网的方式,确保同一时间只有一个Thread来做操作.

Thread2取得lock在操作Thread1就必须等待Thread2执行完,在取值=>改值..等等动作

只是使用lock会降低些许吞吐量(但资料正确性是最重要的),所以要注意使用lock范围大小

CAS(compare and swap)提高效率

CAS是利用compare and swap来确保资料Atomic.

因为CAS可以取得数值记忆体空间来比较给值并且他也是一条CPU原语具有原子性 cpu 硬体同步原语

前面有说过在Balance -= 10;这段程序码会拆成下面动作

  1. 将执行个体变数中的值载入至register。
  2. 将载入值减10
  3. 将异动後值放回原本值的Memory。
balance -= 10;

会拆解成类似下面动作

int temp = balance;
temp = temp -10;
balance = temp;

假如在取balance(附值给temp)跟把值重新写入balance中间有其他Thread来操作,就会造成所谓Data Racing,因为对於我们来说上面後两部有不可分割性(Atomic).

而这时候我们就可以使用CAS算法来帮我们解决问题,在C#如果我们想要达成变数修改的Atomic可以透过Interlocked类别

使用Interlocked提高效率

在C#中我们可以使用Interlocked这个类别

对於Int,Long相关操作都有封装成method.

下面这段虚拟码解释balance -= 10;CAS中类似下面效果

//original
int temp = balance;
temp = temp -10;
balance = temp;

//CAS 
int oldValue = balance;
int newValue = oldValue - 1;
//compare value source and set
Interlocked.CompareExchange(ref balance,newValue,oldValue);

//Interlocked.CompareExchange类似下面做法,但具有Atomic
//if(ref balance == oldValue){
//	balance = newValue;
//}

主要是把compare value source and set这段包成一个不可分割区段达到Atomic.

> 我上面用ref来表示从记忆体位置取得balance原始资料

  • Exchange:把值改成另一个数值 具有Atomic
  • Decrement:把数值-- 具有Atomic
  • Increment:把数值++ 具有Atomic

除了上面我们还可以针对Reference Type做Atomic有兴趣的人在自行了解

class Program
{
	static object _lock = new object();
	static void Main(string[] args)
	{
		Stopwatch sw = new Stopwatch();
		int balanceValue = 10000000;
		Member member = new Member() { Balance = balanceValue };
		List<Task> tasks = new List<Task>();
		sw.Start();
		for (int i = 0; i < 1000000; i++)
		{
			tasks.Add(Task.Run(() => member.UpdateBalance()));
		}
		Task.WaitAll(tasks.ToArray());
		sw.Stop();
		Console.WriteLine("Lock Version");
		Console.WriteLine($"member remaining balance is {member.Balance}");
		Console.WriteLine($"Exec Time Cost : {sw.ElapsedMilliseconds}");

		tasks.Clear();
		member.Balance = balanceValue;
		sw.Restart();
		for (int i = 0; i < 1000000; i++)
		{
			tasks.Add(Task.Run(() => member.UpdateBalanceByInterlock()));
		}
		Task.WaitAll(tasks.ToArray());
		sw.Stop();
		Console.WriteLine("InterLocked Version:");
		Console.WriteLine($"member remaining balance is {member.Balance}");
		Console.WriteLine($"Exec Time Cost : {sw.ElapsedMilliseconds}");

		Console.ReadKey();
	}
}

public class Member {
	object _lock = new object();

	public int Balance { get; set; }

	public void UpdateBalance()
	{
		lock (_lock)
		{
			Balance -= 10;
		}
	}

	public void UpdateBalanceByInterlock()
	{
		int val = 0;
		Balance = Interlocked.Exchange(ref val, Balance -= 10);
	}
}

下图是比较两者执行时间还有并发计算数值,能发现数值两个都正确计算出0,但Interlocked执行时间少於lock版本.

Interlocked效率会比较高是因为block会造成Thread的blocking等待浪费,但Interlocked核心概念是在这段话Atomic取得资料跟原值比较(如果资料还没改就把值修改进Memory中)

所以效率就会比lock好很多

小结

在有些情境适合使用Lock(例如许多操作需要有一致的Atomic)就比较适合.

Interlocked适合用在对於数值的Atomic.

在多执行绪的世界中要顾虑的点真的很多,稍有不甚就会造成很多错误.

因为多执行绪有许多地方需要注意,不然执行效率会不如单执行绪.

我慢慢可以理解为什麽Redis,Node.js一开始要使用sigel Thread来运作了...


<<:  错误接受率 (FAR) 和错误拒绝率 (FRR)

>>:  最新UiPath常见入门问题大汇整║持续更新中...

Internxt Drive - 替代 Google Drive 免费 2 GB 云端空间服务,采用加密与分布式技术保护你的网路资料

Internxt Drive 是世界上最安全的云端储存服务之一,他采用客户端加密与分布布技术,使得所...

[Day 14] 阿嬷都看得懂的 style 标签怎麽用

阿嬷都看得懂的 style 标签怎麽用 昨天我们介绍了 CSS 选择器,所以终於知道该怎麽把独立收整...

Logo 语言和你 SAY HELLO!!

第二十六天 各位点进来的朋友,你们好阿 小的不才只能做这个系列的文章,但还是希望分享给点进来的朋友,...

【从零开始的 C 语言笔记】第二十二篇-多重回圈 & 九九乘法表

不怎麽重要的前言 上一篇介绍了需要与if条件式结合且与回圈控制有关的语法,基本上我们已经把基础的程序...

Dungeon Mizarka 016

调整ActivityLog 今天花了时间调整ActivityLog,也就是画面上方秀出讯息的地方。原...