C# 學習筆記(8) 控件的跨線程通路
本文參考部落格
C#多線程 https://www.cnblogs.com/dotnet261010/p/6159984.html
C# 線程與程序 https://www.cnblogs.com/craft0625/p/7496682.html
C# 跨線程調用控件https://www.cnblogs.com/TankXiao/p/3348292.html
對于c#中的線程和程序,這兩篇文章講的相當到位了,本文隻是為了學習做的摘要。
線程
- 線程是什麼?
線程(Thread)是程序中的基本執行單元,一個程序可以包含若幹個線程,在.NET應用程式中,調用Main()方法時系統就會自動建立一個主線程。
- 為什麼要多線程?
在知道為什麼要多線程前,先要知道什麼是多線程?假設現在CPU有A、B、C、D、E五個任務,單線程就是A任務執行完畢接着執行B任務,B任務執行完畢接着執行C…就是一個一個的執行。多線程就是将時間分成時間片,假設一個時間片1ms,CPU在執行任務時,第一個1ms執行A任務,當第二個1ms來臨時,CPU儲存A任務的工作環境,然後去執行B任務…通過時間片任務ABCDE輪流執行,由于CPU很快,時間片時間很短,給我們一種ABCDE五個任務同時在運作的假象,這個就是多線程。
- 目前電腦都是多核多CPU的,在同一個時間片内,每一個CPU都可以運作一個線程,多線程可以提高CPU效率。
-
在多線程程式中,一個線程必須等待的時候,CPU可以運作其它的線程而不是等待,這樣就大大提高了程式的效率。
簡單來說,多線程主要是為了提高效率的,但是多線程需要注意 線程之間對共享資源的通路會互相影響,必須解決共享資源的競争冒險問題。
單線程窗體卡死
建立一個窗體,在窗體上拖兩個控件,textbox和按鈕

将textbox滾動條屬性打開
輕按兩下按鈕,添加一個按鈕單擊事件,當按鈕單擊,textbox中列印10000一行資料。
private void btnPrint_Click(object sender, EventArgs e)
{
for(int i = 0; i < 10000; i++)
{
txbLog.AppendText("這是第" + i + "行\r\n");
}
}
發現當按鈕按下後,textbox中有資料不斷顯示上去,但是這時無法操作窗體,窗體的移動,關閉等都無法操作,也就是俗稱的窗體卡死。其實這就是單線程的弊端,當按鍵單擊事件沒有執行完畢,就不去響應其他的操作。
建立多線程
void PrintfLog()
{
for (int i = 0; i < 10000; i++)
{
txbLog.AppendText("這是第" + i + "行\r\n");
}
}
private void btnPrint_Click(object sender, EventArgs e)
{
//建立線程
Thread printThread = new Thread(PrintfLog);
//告訴系統,這個線程準備好了,可以開始執行了,至于什麼時候執行,看系統安排
printThread.Start();
}
多線程建立十分簡單,隻需要建立和标記為開始狀态即可。但是上面的代碼如果直接仿真,會發現系統會抛出異常
根據異常提示,我們可以知道 txbLog控件是主線程建立的,不允許在其他線程直接調用它(在.NET上執行的是托管代碼,C#強制要求這些代碼必須是線程安全的,即不允許跨線程通路Windows窗體的控件。)
控件的跨線程通路
- 在窗體的加載事件中,将C#内置控件(Control)類的CheckForIllegalCrossThreadCalls屬性設定為false,屏蔽掉C#編譯器對跨線程調用的檢查。
實際的軟體開發中,做如此設定是不安全的(不符合.NET的安全規範)
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
Control.CheckForIllegalCrossThreadCalls = false;
}
void PrintfLog()
{
for (int i = 0; i < 10000; i++)
{
txbLog.AppendText("這是第" + i + "行\r\n");
}
}
private void btnPrint_Click(object sender, EventArgs e)
{
//建立線程
Thread printThread = new Thread(PrintfLog);
//告訴系統,這個線程準備好了,可以開始執行了,至于什麼時候執行,看系統安排
printThread.Start();
}
}
- 使用delegate和invoke來從其他線程中調用控件
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
//聲明委托類
delegate void txbLogPrintDelegate(string str);
void PrintfLog()
{
for (int i = 0; i < 10000; i++)
{
//通過txbLog的InVoke 告訴建立txbLog的線程,需要操作txbLog控件
txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, "這是第" + i + "行\r\n");
}
}
void TxbLogAppendText(string str)
{
//建立txbLog的線程也就是主線程 操作txbLog控件
this.txbLog.AppendText(str);
}
private void btnPrint_Click(object sender, EventArgs e)
{
//建立線程
Thread printThread = new Thread(PrintfLog);
//告訴系統,這個線程準備好了,可以開始執行了,至于什麼時候執行,看系統安排
printThread.Start();
}
}
使用delegate和invoke來從其他線程中調用控件本質上還是通過主線程來操作控件,但是窗體并沒有像單線程那樣直接卡死,為什麼? 對比在主線程裡面操作控件和其他線程通過回調操作控件消耗時間,發現其他線程通過回調操作控件花費的時間遠遠大于主線程直接操作控件,猜測其他線程通過回調操作控件時,會發一個通知告訴主線程,主線處理完後就去處理其他UI事件了,表現也就是窗體沒有卡死。 換了一台電腦卡死了,操作UI界面最終都要回到主線程,是以在頻繁操作界面時,UI卡死嗯 挺正常的,畢竟你不能讓他一邊不停顯示,一邊又要移動,優化的話隻能一邊不全速的顯示,另一半才能進行其他操作。
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
//聲明委托類
delegate void txbLogPrintDelegate(string str);
void PrintfLog()
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
//通過txbLog的InVoke 告訴建立txbLog的線程,需要操作txbLog控件
txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, ":這是第" + i + "行\r\n");
}
stopwatch.Stop();
txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, stopwatch.ElapsedMilliseconds + "\r\n");
}
void TxbLogAppendText(string str)
{
//建立txbLog的線程也就是主線程 操作txbLog控件
this.txbLog.AppendText(Thread.CurrentThread.ManagedThreadId.ToString() + str);
}
private void btnPrint_Click(object sender, EventArgs e)
{
txbLog.AppendText("主線程ID:" + Thread.CurrentThread.ManagedThreadId.ToString()+ "\r\n");
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
//通過txbLog的InVoke 告訴建立txbLog的線程,需要操作txbLog控件
txbLog.AppendText(Thread.CurrentThread.ManagedThreadId.ToString() + ":這是第" + i + "行\r\n");
}
stopwatch.Stop();
txbLog.AppendText(stopwatch.ElapsedMilliseconds + "\r\n");
//建立線程
Thread printThread = new Thread(PrintfLog);
//告訴系統,這個線程準備好了,可以開始執行了,至于什麼時候執行,看系統安排
printThread.Start();
}
}
- 使用delegate和BeginInvoke來從其他線程中控制控件
這個和方法二類似,隻不過将 txbLog.Invoke 換成 txbLog.BeginInvoke 差別就是 Invoke方法是同步的, 它會等待主線程完成,BeginInvoke方法是異步的, 它會另起一個線程去完成工作線程 它會給主線程發生一個消息,等主線程空閑時再去執行
用上面代碼 實際測試發現 txbLog.BeginInvoke也會導緻界面卡死,不過想想也知道,操作UI界面最終都要回到主線程,是以在頻繁操作界面時,UI卡死嗯 挺正常的,畢竟你不能讓他一邊不停顯示,一邊又要移動,優化的話隻能一邊不全速的顯示,另一半才能進行其他操作。後面在别的電腦上測試,發現用 Invoke 和 BeginInvoke都不能避免UI卡死,最終給線程加了一個休眠,不讓UI全速顯示,嗯正常了,筆者初學乍到,如果大佬有什麼更好的方法,敬請告知。
void PrintfLog()
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 1000; i++)
{
//通過txbLog的InVoke 告訴建立txbLog的線程,需要操作txbLog控件
txbLog.BeginInvoke((txbLogPrintDelegate)TxbLogAppendText, ":這是第" + i + "行\r\n");
Thread.Sleep(1);
}
stopwatch.Stop();
txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, stopwatch.ElapsedMilliseconds + "\r\n");
}
- 這樣的寫法有一個煩人的地方:對不同的控件寫法不同。對于TextBox,要TextBoxObject.Invoke,對于Label,又要LabelObject.Invoke。有沒有統一一點的寫法呢?
主視窗類本身也有Invoke方法。如果你不想對不同的控件寫法不一樣,可以全部用this.Invoke:
推薦使用主視窗類本身的Invoke方法再加上Lamda表達式進行控件跨線程通路
void PrintfLog()
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
//通過txbLog的InVoke 告訴建立txbLog的線程,需要操作txbLog控件
//this.Invoke((txbLogPrintDelegate)TxbLogAppendText, ":這是第" + i + "行\r\n");
this.Invoke(new Action<string>((string str) => { txbLog.AppendText(Thread.CurrentThread.ManagedThreadId.ToString() + str); }), ":這是第" + i + "行\r\n");
}
stopwatch.Stop();
txbLog.Invoke((txbLogPrintDelegate)TxbLogAppendText, stopwatch.ElapsedMilliseconds + "\r\n");
}
- 使用BackgroundWorker元件
正常情況下,有一個比較耗時的操作比如說算法很複雜、要寫入資料庫等IO操作,CPU不想在這裡傻等(隻有主線程的話,傻等會導緻界面假死)進而建立一個線程來做這個耗時操作,耗時操作結束後操作UI界面給使用者一個回報,上面介紹的方法2、3都是控件的跨線程通路方法。下面介紹一個官方為解決這種問題提供的一種法方。
仔細研究會發現這種方法是基于方法2實作的,隻不過用了官方的殼,其本質是在背景線程裡幹一些耗時操作,耗時操作幹完後,通過回調在主線程裡面完成其他操作,自己完全可以通過方法2實作
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
void PrintfLog(object sender, DoWorkEventArgs e)
{
// 這裡系統會自動生成一個背景線程
// 可以在這裡做一些費時的,複雜的操作
StringBuilder stringBuilder = new StringBuilder(1024 * 1024);
for (int i = 0; i < 10000; i++)
{
//生成log資訊
stringBuilder.Append(Thread.CurrentThread.ManagedThreadId.ToString() + ":這是第" + i + "行\r\n");
}
//做完耗時操作後将結果通過 e.Result 傳遞出去
e.Result = stringBuilder.ToString();
}
void TxbLogAppendText(object sender, RunWorkerCompletedEventArgs e)
{
//這時背景線程已經完成,并傳回了主線程,是以可以直接使用UI控件了
this.txbLog.AppendText(e.Result.ToString());
}
private void btnPrint_Click(object sender, EventArgs e)
{
txbLog.AppendText("主線程ID:" + Thread.CurrentThread.ManagedThreadId.ToString() + "\r\n");
using (BackgroundWorker bw = new BackgroundWorker())
{
//背景線程需要執行的委托
bw.RunWorkerCompleted += new RunWorkerCompletedEventHandler(TxbLogAppendText);
//背景線程結束後 會調用該委托
bw.DoWork += new DoWorkEventHandler(PrintfLog);
//如果線程需要參數,可以傳入參數 DoWorkEventArgs e.Argument調用參數
bw.RunWorkerAsync();
}
}
}
UI假死總結
上面2、3舉的例子說明了一個問題,UI控件都在主線程 建立的情況下,頻繁操作UI導緻界面卡死是正常操作,可以通過給頻繁操作的控件加上一定休眠時間在一定程度上解決
前背景線程
前台線程:隻有所有的前台線程都結束,應用程式才能結束。預設情況下建立的線程都是前台線程
背景線程:隻要所有的前台線程結束,背景線程自動結束。通過Thread.IsBackground設定背景線程。必須在調用Start方法之前設定線程的類型,否則一旦線程運作,将無法改變其類型。
- 通過BeginXXX方法運作的線程都是背景線程。
- 我們在使用上面delegate和invoke來從其他線程中調用控件 的代碼進行列印時,再尚未列印結束的時候點選窗體上的叉号關掉窗體,會發現系統抛出異常,告訴我們目标不存在,為什麼目标不存在?
建立的線程預設是前台線程,關閉窗體後前台線程并不會結束,是以還在調用控件txbLog,但是txbLog控件是主線程的,主線程已經關閉了,控件自然也就銷毀了,是以在建立線程時,我們可以根據線程的重要程度,将線程設定為前台或者背景,一般情況下設定為背景線程即可(主線程結束後,背景線程就會自動結束);重要的線程,設定為前台線程,如果需要可以在窗體FormClosing事件中處理
//建立線程
Thread printThread = new Thread(PrintfLog);
//設定為背景線程
printThread.IsBackground = true;
//告訴系統,這個線程準備好了,可以開始執行了,至于什麼時候執行,看系統安排
printThread.Start();