C# 並發編程


前言

對於現在很多編程語言來說,多線程已經得到了很好的支持,

以至於我們寫多線程程序簡單,但是一旦遇到並發產生的問題就會各種嘗試。

因為不是明白為什么會產生並發問題,並發問題的根本原因是什么。

接下來就讓我們來走近一點並發產生的那些問題。

猜猜是多少?

 public class ThreadTest_V0
    {
        public int count = 0;
        public void Add1()
        {
            int index = 0;
            while (index++ < 1000000)//100萬次
            {
                ++count;
            }
        }

        public void Add2()
        {
            int index = 0;
            while (index++ < 1000000)//100萬次
            {
                count++;
            }
        }
    }

結果是多少?

static void V0()
        {
            ThreadTest_V0 testV0 = new ThreadTest_V0();
            Thread th1 = new Thread(testV0.Add1);
            Thread th2 = new Thread(testV0.Add2);

            th1.Start();
            th2.Start();
            th1.Join();
            th2.Join();

            Console.WriteLine($"V0:count = {testV0.count}");
        }

答案:100萬 到 200萬之間的隨機數。

為什么?

接下來我們去深入了解一下為什么會這樣?

一、可見性

首先我們來到 “可見性” 這個陌生的詞匯身邊。

通過一番交談了解到:

對可見性進行一下總結就是我改的東西你能同時看到。

1.1 背景

解讀一下呢,就像下面這樣:

CPU 內存 硬盤 ,處理速度上存在很大的差距,為了彌補這種差距,也是為了利用CPU強大計算能力。

CPU 和內存之前加入了緩存,就是我們經常聽說的 寄存器緩存、L1、2、3級緩存。

應該的處理流程是這樣的:讀取內存數據,緩存到CPU緩存中,CPU進行計算后,從CPU緩存中寫回內存。

1.2 線程切換

還有一點 我們都知道多線程其實是通過切換時間片來達到 “同時” 處理問題的假象。

線程切換

1.3 單核時代

你也發現了,對於單核來說,程序其實還是串行開發的。

單核CPU

就像是 “一個人” ,東干點,西干點,如果切換頻率上再快點速度,比我們的眨眼時間還短呢?那……

接下來,我們進入了多核時代。

1.4多核時代

顧名思義,多個CPU,也就是每個CPU核心都有自己的緩存體系,但是內存只有一份。

比如CPU就是我么們的本地緩存,而內存相當於數據庫。

我們每個人的本地緩存極有可能是不一樣的,如果我們拿着這些緩存直接做一些業務計算,

結果可想而知,多核時代,多線程並發也會有這樣的問題 — CPU緩存的數據不一樣咋辦?

多核CPU

1.5 volatile

這是CLR 為我們提出的解決方案,就是在遇到可見性引發的並發問題時,使用 volatile 關鍵字。

就是告訴 CPU,我不想用你的緩存,所有的請求都直接讀寫內存。

一句話,就是禁用緩存。

看上去這樣就能解決並發問題了吧?也不全是,還有下面這種槍情況。

二、有序性

字面意義就是有順序,那么是什么有順序呢?-- 代碼

代碼其實並不是我們所寫的那樣一五一十地執行,以C# 為例:

代碼 --> IL --> Jit --> cpu 指令

代碼 通過編譯器的優化生成了IL

CPU也會根據自己的優化重新排列指令順序

至少兩個點會有存在調整 代碼順序/指令順序的可能。

2.1 猜猜 Debug和Release 運行結果各是多少

public class VolatileTest
    {
        public int falg = 0;
    }
static void VolatileTest()
        {
            VolatileTest volatiler = new VolatileTest();

            new Thread(
               p =>
               {
                   Thread.Sleep(1000);
                   volatiler.falg = 255;
               }).Start();

            while (true)
            {
                if (volatiler.falg == 255)
                {
                    break;
                }
            };

            Console.WriteLine("OK");
        }

主線程一直自旋,直到子線程將值改變就退出,顯示 “OK”

Debug 版本,執行結果:

Debug

Release 版本,執行結果:

Release

為什么會這樣,因為我們的代碼會經過編譯器優化,CPU指令優化,

語句的順序會發生改變,但是這樣也是這種離奇bug產生的一種方式。

怎么避免它?

2.2 volatile

沒錯,依然是它,不僅僅是禁用cpu緩存,而且還能禁止指令和編譯優化。

至少上面的那個例子我們可以再試試:

public class VolatileTest
    {
        public volatile int falg = 0;
    }

volatile 發布版

到這里應該就可以了吧,volatile 真好用,一個關鍵字就搞定。

正如你所想,依然沒有結束。

三、原子性

我們平時經常遇到要給一段代碼區域加上鎖,比如這樣:

lock (lockObj)
                {
                    count++;
                }

我么們為什么要加鎖呢?你說為了線程同步,為什么加鎖就能保證線程同步而不是其他方式?

3.1count++

說到這里,我們需要再了解一個問題:count++

我們經常寫這樣的代碼,那么count++ 最終轉換成cpu指令會是什么樣子呢?

指令1: 從內存中讀取 count

指令2:將 count +1

指令3:將新計算的count值,寫回內存

我們將這個count++ 操作和線程切換進行結合

count++ 線程切換

這里才是真正解答了最開始為什么是 100萬到200之間的隨機數。

解決 原子性問題的方法有很多,比如鎖

3.2 lock

加鎖這個代碼我就暫且忽略,因為lock我們並不陌生。

但是需要明白一點,lock() 是微軟提供給我們的語法糖,其實最終使用的是 Monitor,並且做了異常和資源處理。

lock

CLR 鎖原理

多個線程訪問同一個實例下的共享變量,同時將同步塊索引從 -1 改成CLR維護的同步塊數組,

用完就會將實例的同步快變成-1

3.3 Monitor

上面提到了隱姓埋名的Monitor,其實我們也可以拋頭露面地使用Monitor

這里也不具體細說。具體使用可以參照上面圖片。

3.4 System.Threading.Interlocked

官方定義:原子性的簡單操作,累加值,改變值等

區區 count++ 使用lock 有點浪費,我們使用更加輕量級的 Interlocked,

為我們的 count ++ 保駕護航。

 public class ThreadTest_V3
    {
        public volatile int count = 0;
        public void Add1()
        {
            int index = 0;
            while (index++ < 1000000)//100萬次
            {
                Interlocked.Add(ref count, 1);
            }
        }

        public void Add2()
        {
            int index = 0;
            while (index++ < 1000000)//100萬次
            {
                Interlocked.Add(ref count, 1);
            }
        }
    }

結果不多說,依然穩穩的 200萬。

3.5 System.Threading.SpinLock結構

自旋鎖結構,可以這樣理解。

多線程訪問共享資源時,只有一個線程可以拿到鎖,其他線程都在原地等待,

直到這個鎖被釋放,原地等待的資源又一次進行搶占,以此類推。

在具體使用 System.Threading.SpinLock結構 之前,我們根據剛剛講過的 System.Threading.Interlocked,進行一下改造:

public struct Spin
    {
        private int m_lock;//0=unlock ,1=lock
        public void Enter()
        {
            while (System.Threading.Interlocked.Exchange(ref m_lock, 1) != 0)
            {
                //可以限制自旋次數和時間,自動斷開退出
            }
        }

        public void Exit()
        {
            System.Threading.Interlocked.Exchange(ref m_lock, 0);
        }
    }
public class ThreadTest_V4
    {
        private Spin spin = new Spin();
        public volatile int count = 0;
        public void Add1()
        {
            int index = 0;
            while (index++ < 1000000)//100萬次
            {
                spin.Enter();
                count++;
                spin.Exit();
            }
        }

        public void Add2()
        {
            int index = 0;
            while (index++ < 1000000)//100萬次
            {
                spin.Enter();
                count++;
                spin.Exit();
            }
        }
    }

Enter() , m_lock 從0到1,就是加鎖;

鎖的是共享資源 count;

其他線程原地自旋等待(循環)

Exit(),m_lock 從1到0,就是解鎖;

System.Threading.SpinLock 結構和以上實現思想類似。

后面的內容就簡單提一下定義和應用場景,有必要的就可以單獨細查。

3.6 System.Threading.SpinWait結構

提供了基於自旋等待支援。
在線程必須等待發出事件信號或滿足條件時方可使用.

3.7 System.Threading.ReaderWriterLockSlim類

授予獨占訪問共享資源的寫作,
並允許多個線程同時訪問資源進行讀取。

3.8 CAS

cas 核心思想:
將 count 從內存讀取出來並賦值給一個局部變量,叫做 originalData;

然后這個局部變量 +1 並賦值給新值,叫做 newData;

再次從內存中將count讀取出來,如果originalData ==count,

說明沒有線程修改內存中count值,可以將新值存儲到內存中。

反之則可以選擇自旋或者其他策略。

當然還有進程之間的同步,這里就不一一展開說了。
總結一下:
並發三要素 可見性、有序性、原子性

幾種鎖原理和CAS操作


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM