深入瞭解C#多線程安全

前面兩篇文章,分別簡述瞭多線程的使用和發展歷程,但是使用多線程無法避免的一個問題就是多線程安全。那什麼是多線程安全?如何解決多線程安全?本文主要通過一些簡單的小例子,簡述多線程相關的問題,僅供學習分享使用,如有不足之處,還請指正。

什麼是多線程安全?

一段程序,單線程和多線程執行結果不一致,就表示存在多線程安全問題,即多線程不安全。

多線程安全示例

1. 多線程不安全示例1

假如我們有一個需求,需要輸出5個線程,且線程序號按0-4命名,我們編寫代碼如下:

private void btnTask1_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程不安全示例btnTask1_Click**************");

    for (int i = 0; i < 5; i++)
    {
        Task.Run(() =>
        {
            Console.WriteLine($"【BEGIN】**************這是第 {i} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
            Thread.Sleep(2000);
            Console.WriteLine($"【 END 】**************這是第 {i} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
        });
    }

    Console.WriteLine("【結束】**************線程不安全示例btnTask1_Click**************");
}

然後運行示例,如下所示:

通過對以上示例進行分析,得出結論如下:

1.在for循環中,啟動的5個線程,線程序號都是5,並沒有按照我們預期的結果【0,1,2,3,4】進行輸出。

2.經過分析發現,因為for循環中,i是同一個變量,線程啟動是異步進行的,存在延遲,當線程啟動時,for循環已經結束,i的值為5,所以才導致線程序號和預期不一致。

為瞭解決上述問題,可以通過引入局部變量來解決,即每次循環聲明一個變量,循環5次,存在5個變量,則相互之間不會覆蓋。如下所示:

private void btnTask1_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程不安全示例btnTask1_Click**************");

    for (int i = 0; i < 5; i++)
    {
        int k = i;
        Task.Run(() =>
        {
            Console.WriteLine($"【BEGIN】**************這是第 {k} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
            Thread.Sleep(2000);
            Console.WriteLine($"【 END 】**************這是第 {k} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
        });
    }

    Console.WriteLine("【結束】**************線程不安全示例btnTask1_Click**************");
}

運行優化後的示例,如下所示:

通過運行示例發現,局部變量可以解決相應的問題。

2. 多線程不安全示例2

假如我們有一個需求:將0到200增加到一個列表中,采用多線程來實現,如下所示:

private void btnTask2_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程不安全示例btnTask1_Click**************");
    List<int> list = new List<int>();
    List<Task> tasks = new List<Task>();
    for (int i = 0; i < 200; i++)
    {
        tasks.Add( Task.Run(() =>
        {
            list.Add(i);
        }));
    }
    Task.WaitAll(tasks.ToArray());
    string res = string.Join(",", list);
    Console.WriteLine($"列表長度: {list.Count} ,列表內容:{res}");
    Console.WriteLine("【結束】**************線程不安全示例btnTask1_Click**************");
}

通過運行示例,如下所示:

通過對以上示例進行分析,得出結論如下:

1.列表的記錄條數不對,會少。

2.列表的元素內容與預期的內容不一致。

針對上述問題,采用中間局部變量的方式,可以解決嗎?不妨一試,修改後的 代碼如下:

private void btnTask2_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程不安全示例btnTask1_Click**************");
    List<int> list = new List<int>();
    List<Task> tasks = new List<Task>();
    for (int i = 0; i < 200; i++)
    {
        int k = i;
        tasks.Add( Task.Run(() =>
        {
            list.Add(k);
        }));
    }
    Task.WaitAll(tasks.ToArray());
    string res = string.Join(",", list);
    Console.WriteLine($"列表長度: {list.Count} ,列表內容:{res}");
    Console.WriteLine("【結束】**************線程不安全示例btnTask1_Click**************");
}

運行優化示例,如下所示:

通過運行上述示例,得出結論如下:

1.列表長度依然不對,會小於實際單一線程的長度。註意:多線程列表長度不是一定會小於單一線程運行時列表長度,隻是存在概率,即多個線程存在同時寫入一個位置的概率。

2.列表內容,采用局部變量,可以解決部分問題。

由此可以得出List不是線程安全的數據類型。

加鎖lock

針對多線程的不安全問題,可以通過加鎖進行解決,加鎖的目的:在任意時刻,加鎖塊都之允許一個線程訪問。

加鎖原理

lock實際是一個語法糖,實際效果等同於Monitor。鎖定的是引用對象的一個內存地址引用。所以鎖定對象不可以是值類型,也不可以是null,隻能是引用類型。

lock對象的標準寫法:默認情況下,鎖對象是私有,靜態,隻讀,引用對象。如下所示:

/// <summary>
/// 定義一個鎖對象
/// </summary>
private static readonly object obj = new object();

然後優化程序,如下所示:

private void btnTask2_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程不安全示例btnTask1_Click**************");
    List<int> list = new List<int>();
    List<Task> tasks = new List<Task>();
    for (int i = 0; i < 200; i++)
    {
        int k = i;
        tasks.Add( Task.Run(() =>
        {
            lock (obj)
            {
                list.Add(k);
            }
        }));
    }
    Task.WaitAll(tasks.ToArray());
    string res = string.Join(",", list);
    Console.WriteLine($"列表長度: {list.Count} ,列表內容:{res}");
    Console.WriteLine("【結束】**************線程不安全示例btnTask1_Click**************");
}

運行優化後的示例,如下所示:

通過對上述示例進行分析,得出結論如下:

1.加鎖後,列表在多線程下也變成安全,符合預期的要求。

2.但是由於加鎖的原因,同一時刻,隻能由一個線程進入,其他線程就會等待,所以多線程也變成瞭單線程。

為何鎖對象要用私有類型?

標準寫法,鎖對象是私有類型,目的是為瞭避免鎖對象被其他線程使用,如果被使用,則會相互阻塞,如下所示:

假如,現在有一個鎖對象,在TestLock中使用,如下所示:

public class TestLock
{
    public static readonly object Obj = new object();

    public void Show()
    {

        Console.WriteLine("【開始】**************線程示例Show**************");

        for (int i = 0; i < 5; i++)
        {
            int k = i;
            Task.Run(() =>
            {
                lock (Obj)
                {
                    Console.WriteLine($"【BEGIN】*********T*****這是第 {k} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                    Thread.Sleep(2000);
                    Console.WriteLine($"【 END 】*********T*****這是第 {k} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                }
            });
        }

        Console.WriteLine("【結束】**************線程示例Show**************");
    }
}

同時在FrmMain中使用,如下所示:

private void btnTask3_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程示例btnTask3_Click**************");
    //類對象中多線程
    TestLock.Show();
    //主方法中多線程
    for (int i = 0; i < 5; i++)
    {
        int k = i;
        Task.Run(() =>
        {
            lock (TestLock.Obj)
            {
                Console.WriteLine($"【BEGIN】*********M*****這是第 {k} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                Thread.Sleep(2000);
                Console.WriteLine($"【 END 】*********M*****這是第 {k} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
            }
        });
    }

    Console.WriteLine("【結束】**************線程示例btnTask3_Click**************");
}

運行上述示例,如下所示:

通過上述示例,得出結論如下:

1.T和M是成對相鄰,且各代碼塊交互出現。

2.多個代碼塊,共用一把鎖,是會相互阻塞的。這也是為啥不建議使用public修飾符的原因,避免被不恰當的加鎖。

如果使用不同的鎖對象,多個代碼塊之間是可以並發的【T和M是不成對,且不相鄰出現,但是有同一代碼塊的內部順序】,效果如下:

為什麼鎖對象要用static類型?

假如對象不是static類型,那麼鎖對象就是對象屬性,不同的對象之間是相互獨立的,所以不同通對象調用相同的方法,就會存在並發的問題,如下所示:

修改TestLock代碼【去掉static】,如下所示:

public class TestLock
{
    public  readonly object Obj = new object();

    public  void Show(string name)
    {

        Console.WriteLine("【開始】**************線程示例Show--{0}**************",name);

        for (int i = 0; i < 5; i++)
        {
            int k = i;
            Task.Run(() =>
            {
                lock (Obj)
                {
                    Console.WriteLine($"【BEGIN】*********T*****這是第 {k}--{name} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                    Thread.Sleep(2000);
                    Console.WriteLine($"【 END 】*********T*****這是第 {k}--{name} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                }
            });
        }

        Console.WriteLine("【結束】**************線程示例Show--{0}**************",name);
    }
}

聲明兩個對象,分別調用Show方法,如下所示:

private void btnTask4_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程示例btnTask3_Click**************");
    TestLock testLock1 = new TestLock();
    testLock1.Show("first");

    TestLock testLock2 = new TestLock();
    testLock2.Show("second");
    Console.WriteLine("【結束】**************線程示例btnTask3_Click**************");
}

測試示例,如下所示:

通過以上示例,得出結論如下:

非靜態鎖對象,隻在當前對象內部進行允許同一時刻隻有一個線程進入,但是多個對象之間,是相互並發,相互獨立的。所以建議鎖對象為static對象。

加鎖鎖定的是什麼?

在lock模式下,鎖定的是內存引用地址,而不是鎖定的對象的值。假如將Form的鎖對象的類型改為字符串,如下所示:

/// <summary>
/// 定義一個鎖對象
/// </summary>
private static readonly string obj = "花無缺";

同時TestLock類的鎖對象也改為字符串,如下所示:

public class TestLock
{
    private static  readonly string obj = "花無缺";

    public static  void Show(string name)
    {

        Console.WriteLine("【開始】**************線程示例Show--{0}**************",name);

        for (int i = 0; i < 5; i++)
        {
            int k = i;
            Task.Run(() =>
            {
                lock (obj)
                {
                    Console.WriteLine($"【BEGIN】*********T*****這是第 {k}--{name} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                    Thread.Sleep(2000);
                    Console.WriteLine($"【 END 】*********T*****這是第 {k}--{name} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
                }
            });
        }

        Console.WriteLine("【結束】**************線程示例Show--{0}**************",name);
    }
}

運行上述示例,結果如下:

通過上述示例,得出結論如下:

1.字符串是一種特殊的鎖類型,如果字符串的值一致,則認為是同一個鎖對象,不同對象之間會進行阻塞。因為string類型是享元的,在內存堆裡面隻有一個花無缺。

2.如果是其他類型,則是不同的鎖對象,是可以相互並發的。

3.說明鎖定的是內存引用地址,而非鎖定對象的值。

泛型鎖對象

如果TestLock為泛型類,如下所示:

1 public class TestLock<T>
 2 {
 3     private static  readonly object obj = new object(); 4 
 5     public static  void Show(string name)
 6     {
 7 
 8         Console.WriteLine("【開始】**************線程示例Show--{0}**************",name);
 9 
10         for (int i = 0; i < 5; i++)
11         {
12             int k = i;
13             Task.Run(() =>
14             {
15                 lock (obj)
16                 {
17                     Console.WriteLine($"【BEGIN】*********T*****這是第 {k}--{name} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
18                     Thread.Sleep(2000);
19                     Console.WriteLine($"【 END 】*********T*****這是第 {k}--{name} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
20                 }
21             });
22         }
23 
24         Console.WriteLine("【結束】**************線程示例Show--{0}**************",name);
25     }
26 }

那麼在調用時,會相互阻塞嗎?調用代碼如下:

private void btnTask5_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程示例btnTask5_Click**************");
    TestLock<int>.Show("AA");
    TestLock<string>.Show("BB");
    Console.WriteLine("【結束】**************線程示例btnTask5_Click**************");
}

運行上述示例,如下所示:

通過分析上述示例,得出結論如下所示:

1.對於泛型類,不同類型參數之間是可以相互並發的,因為泛型類針對不同類型參數會編譯成不同的類,那對應的鎖對象,會變成不同的引用類型。

2.如果鎖對象為字符串類型,則也是會相互阻塞的,隻是因為字符串是享元模式。

3.泛型T的不同,會編譯成不同的副本。

遞歸加鎖

如果在遞歸函數中進行加鎖,會造成死鎖嗎?示例代碼如下:

private void btnTask6_Click(object sender, EventArgs e)
{
    Console.WriteLine("【開始】**************線程示例btnTask6_Click**************");
    this.add(1);
    Console.WriteLine("【結束】**************線程示例btnTask6_Click**************");
}

private int num = 0;

private void add(int index) {
    this.num++;
    Task.Run(()=> {
        lock (obj)
        {
            Console.WriteLine($"【BEGIN】**************這是第 {num} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");
            Thread.Sleep(2000);
            Console.WriteLine($"【 END 】**************這是第 {num} 個線程,線程ID={Thread.CurrentThread.ManagedThreadId}**************");

            if (num < 5)
            {
                this.add(index);
            }
        }
    });
}

運行上述示例,如下所示:

通過運行上述示例,得出結論如下:

在遞歸函數中進行加鎖,會進行阻塞等待,但是不會造成死鎖。 

以上就是深入瞭解C#多線程安全的詳細內容,更多關於C#多線程安全的資料請關註WalkonNet其它相關文章!

推薦閱讀: