學習C#非同步程式設計
P1 執行緒(Thread):建立執行緒
什麼是執行緒Thread
- 執行緒是一個可執行路徑,它可以獨立於其它執行緒執行
- 每個執行緒都在作業系統的程序(Process)內執行,而作業系統程序提供了程式執行的獨立環境。
- 單執行緒應用,在程序的獨立環境裡 只跑一個執行緒,所以該執行緒擁有獨佔權。
- 多執行緒應用,單個程序中會跑多個執行緒,它們會共享當前的執行環境(尤其是記憶體)
- 例如,一個執行緒在後臺讀取資料,另一個執行緒在資料到達後進行展示。
- 這個資料就被稱作是共享的狀態。
例子:
class Program { static void Main(string[] args) { Thread thread = new Thread(WriteY); //開啟一個新的執行緒 Thread thread.Name = "Y Thread..."; thread.Start(); for (int i = 0; i < 1000; i++) Console.WriteLine("x"); Console.ReadKey(); } private static void WriteY() { for(int i = 0; i < 1000; i++) { Console.WriteLine("y"); } } }
- 在單核計算機上,作業系統必須為每個執行緒分配“時間片”(在Windows中通常為20毫秒)來模擬併發,從而導致重複的x和y塊。
- 在多核或多處理器計算機上,這兩個執行緒可以真正地並行執行(可能受到計算機上其他活動程序的競爭)。
術語:執行緒被搶佔
- 執行緒在什麼時候可以稱為被搶佔了:它的執行與另一個執行緒上程式碼的執行交織的那一刻。
執行緒的一些屬性
- 執行緒一旦開始執行,IsAlive就是true,執行緒結束就變成false。
- 執行緒結束的條件就是:執行緒建構函式傳入的委託結束了執行。
- 執行緒一旦結束,就無法再重啟。
- 每個執行緒都有個Name屬性,通常用於除錯
- 執行緒Name只能設定一次,以後更改會丟擲異常。(System.InvalidOperationException:“該屬性已經設定,不能修改。”)
- 靜態的Thread.CurrentThread屬性,會返回當前執行的執行緒。
P2 Thread.Join()&Thread.Sleep()
Join and Sleep
-
呼叫Join方法,就可以等待另一個執行緒結束。
例子:
class Program { static void Main(string[] args) { Thread t = new Thread(WriteY); t.Start(); t.Join();//當前執行緒會等待t執行緒執行結束 Console.WriteLine("執行緒結束"); Console.Read(); } private static void WriteY() { for(int i = 0; i < 1000; i++) { Console.Write("y"); } } }
class Program
{
static Thread thread1, thread2;
static void Main(string[] args)
{
thread1 = new Thread(ThreadProc);
thread1.Name = nameof(thread1);
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = nameof(thread2);
thread2.Start();
Console.Read();
}
private static void ThreadProc()
{
Console.WriteLine($"\nCurrent Thread:{Thread.CurrentThread.Name}");
if (Thread.CurrentThread.Name == nameof(thread1) &&
thread2.ThreadState != ThreadState.Unstarted)
thread2.Join();
Thread.Sleep(4000);
Console.WriteLine($"\nCurrent thread:{Thread.CurrentThread.Name}");
Console.WriteLine($"Thread1:{thread1.ThreadState}");
Console.WriteLine($"Thread2:{thread2.ThreadState}");
}
}
輸出內容:
Current Thread:thread1
Current Thread:thread2
Current thread:thread2
Thread1:WaitSleepJoin
Thread2:Running
Current thread:thread1
Thread1:Running
Thread2:Stopped
新增超時
- 呼叫Join的時候,可以設定一個超時,用毫秒或者TimeSpan都可以。
- 如果返回true,那就是執行緒結束了,如果超時了,就返回false。
例子:
class Program
{
static Thread thread1, thread2;
static void Main(string[] args)
{
thread1 = new Thread(ThreadProc);
thread1.Name = nameof(thread1);
thread1.Start();
thread2 = new Thread(ThreadProc);
thread2.Name = nameof(thread2);
thread2.Start();
Console.Read();
}
private static void ThreadProc()
{
Console.WriteLine($"\nCurrent Thread:{Thread.CurrentThread.Name}");
if (Thread.CurrentThread.Name == nameof(thread1) &&
thread2.ThreadState != ThreadState.Unstarted)
if (thread2.Join(2000))
Console.WriteLine("執行緒2結束");
else
Console.WriteLine("執行緒2超時了");
Thread.Sleep(4000);
Console.WriteLine($"\nCurrent thread:{Thread.CurrentThread.Name}");
Console.WriteLine($"Thread1:{thread1.ThreadState}");
Console.WriteLine($"Thread2:{thread2.ThreadState}");
}
}
輸出:
Current Thread:thread1
Current Thread:thread2
執行緒2超時了
Current thread:thread2
Thread1:WaitSleepJoin
Thread2:Running
Current thread:thread1
Thread1:Running
Thread2:Stopped
-
Thread.Sleep()方法會暫停當前的執行緒,並等待一段時間。
-
注意:
- Thread.Sleep(0)這樣呼叫會導致執行緒立即放棄本身當前的時間片,自動將CPU移交給其他執行緒。
- Thread.Yield()做同樣的事情,但是它只會把執行交給同一處理器上的其它執行緒。
- 當等待Sleep或Join的時候,執行緒處於阻塞的狀態。
Sleep(0)或Yield有時在高階效能除錯的生產程式碼中很有用。它也是一個很好的診斷工具,有助於發現執行緒安全問題:
如果在程式碼中的任何地方插入Thread.Yield()就破壞了程式,那麼你的程式幾乎肯定有bug。
P3 阻塞Blocking
阻塞
-
如果執行緒的執行由於某種原因導致暫停,那麼就認為該執行緒被阻塞了。
- 例如在Sleep或者通過Join等待其它執行緒結束。
-
被阻塞的執行緒會立即將其處理器的時間片生成給其它執行緒,從此就不再消耗處理器時間,直到滿足其阻塞條件為止。
-
可以通過ThreadState這個屬性來判斷執行緒是否處於被阻塞的狀態:
bool blocked = (thread.ThreadState & ThreadState.WaitSleepJoin) != 0;
ThreadState
-
ThreadState是一個flags enum,通過按位的形式,可以合併資料的選項。
// // 摘要: // 指定 System.Threading.Thread 的執行狀態。 [ComVisible(true)] [Flags] public enum ThreadState { // // 摘要: // 執行緒已啟動且尚未停止。 Running = 0, // // 摘要: // 正在請求執行緒停止。 這僅用於內部。 StopRequested = 1, // // 摘要: // 正在請求執行緒掛起。 SuspendRequested = 2, // // 摘要: // 執行緒正作為後臺執行緒執行(相對於前臺執行緒而言)。 此狀態可以通過設定 System.Threading.Thread.IsBackground 屬性來控制。 Background = 4, // // 摘要: // 尚未對執行緒呼叫 System.Threading.Thread.Start 方法。 Unstarted = 8, // // 摘要: // 執行緒已停止。 Stopped = 16, // // 摘要: // 執行緒已被阻止。 這可能是呼叫 System.Threading.Thread.Sleep(System.Int32) 或 System.Threading.Thread.Join、請求鎖定(例如通過呼叫 // System.Threading.Monitor.Enter(System.Object) 或 System.Threading.Monitor.Wait(System.Object,System.Int32,System.Boolean))或線上程同步物件上(例如 // System.Threading.ManualResetEvent)等待的結果。 WaitSleepJoin = 32, // // 摘要: // 執行緒已掛起。 Suspended = 64, // // 摘要: // 已對執行緒呼叫了 System.Threading.Thread.Abort(System.Object) 方法,但執行緒尚未收到試圖終止它的掛起的 System.Threading.ThreadAbortException。 AbortRequested = 128, // // 摘要: // 執行緒狀態包括 System.Threading.ThreadState.AbortRequested 並且該執行緒現在已死,但其狀態尚未更改為 System.Threading.ThreadState.Stopped。 Aborted = 256 }
- 四個最有用的值之一:Unstarted、Running、WaitSleepJoin和Stopped
- ThreadState屬性可用於診斷的目的,但不適用於同步,因為執行緒狀態可能會在測試ThreadState和對該資訊進行操作之間發生變化。
接觸阻塞
- 當遇到下列四種情況的時候,就會接觸阻塞:
- 阻塞條件被滿足
- 操作超時
- 通過Thread.Interrupt()進行打斷
- 通過Thread.Abort()進行中止
上下文切換
- 當執行緒阻塞或解除阻塞時,作業系統將執行上下文切換。這會產生少量開銷,通常為1或2微秒。
I/O-bound vs Compute-bound(或CPU-Bound)
- 一個花費大部分時間等待某事發生的操作稱為I/O-bound
- I/O繫結操作通常涉及輸入或輸出,但這不是硬性要求:Thread.Sleep()也被視為I/O-bound
- 相反,一個花費大部分時間執行CPU密集型工作的操作稱為Compute-bound。
阻塞 vs 忙等待(自旋)
Blocking vs Spinning
-
IO-bound操作的工作方式有兩種:
- 在當前執行緒上同步的等待
- Console.ReadLine(),Thread.Sleep(),Thread.Join()
- 非同步的操作,在稍後操作完成時觸發一個回撥動作。
- 在當前執行緒上同步的等待
-
同步等待的I/O-bound操作將大部分時間花在阻塞執行緒上。
-
它們也可以週期性的在一個迴圈裡進行“打轉(自旋)”
while(DateTime.Now < nextStartTime);
-
在忙等待和阻塞方面有一些細微差別。
- 首先,如果希望條件很快得到滿足(可能在幾微秒之內),則短暫自旋可能會很有效,因為它避免了上下文切換的開銷和延遲。
- .Net Framework提供了特殊的方法和類來提供幫助SpinLock和SpinWait。
- 其次,阻塞也不是零成本。這是因為每個執行緒在生存期間會佔用大約1MB記憶體,並會給CLR和作業系統帶來持續的管理開銷。
- 因此,在需要處理成百上千個併發操作的大量I/O-bound程式的上下文中,阻塞可能會很麻煩。
- 所以,此類程式需要使用基於回撥的方法,在等待時完全撤銷其執行緒。
- 首先,如果希望條件很快得到滿足(可能在幾微秒之內),則短暫自旋可能會很有效,因為它避免了上下文切換的開銷和延遲。
P4 什麼是執行緒安全
本地 vs 共享的狀態
Local 本地獨立
- CLR為每個執行緒分配自己的記憶體棧(Stack),以便使本地變數保持獨立。
Shared共享
- 如果多個執行緒都引用到同一個物件的例項,那麼它們就共享了資料。
- 被Lambda表示式或匿名委託所捕獲的本地變數,會被編譯器轉化為欄位(field),所以也會被共享。
- 靜態欄位(field)也會線上程間共享資料。
執行緒安全 Thread Safety
- 儘可能的避免使用共享狀態。
鎖定與執行緒安全簡介
- 在讀取和寫入共享資料的時候,通過使用一個互斥鎖(exclusive lock)。
- C#使用lock語句來加鎖。
- 當兩個執行緒同時競爭一個鎖的時候(鎖可以基於任何引用型別物件),一個執行緒會等待或阻塞,直到鎖變成可用狀態。
- 在多執行緒上下文中,以這種方式避免不確定性的程式碼就叫做執行緒安全。
- Lock不是執行緒安全的銀彈,很容易忘記對欄位加鎖,lock也會引起一些問題(死鎖)。
P5 向執行緒傳遞資料&異常處理
向執行緒傳遞資料
- 如果你想往執行緒的啟動方法裡傳遞引數,最簡單的方式是使用lambda表示式,在裡面使用引數呼叫方法。(例子Lambda)
- 甚至可以把整個邏輯都放在lambda裡面。
向執行緒傳遞資料在C#3.0之前
-
在C#3.0之前,沒有lambda表示式。可以使用Thread的Start方法來傳遞引數。
-
Thread的過載建構函式可以接受下列兩個委託之一作為引數:
public delegate void ThreadStart(); public delegate void ParameterizedThreadStart(object obj);
Lambda表示式與被捕獲的變數
- 使用Lambda表示式可以很簡單的給Thread傳遞引數。但是執行緒開始後,可能會不小心修改了被捕獲的變數,這要多加註意。
異常處理
- 建立執行緒時在作用範圍內的try/catch/finally塊,線上程開始執行後就與執行緒無關了。
- 在WPF、WinForm裡,可以訂閱全域性異常處理事件:
- Application.DispatcherUnhandledException
- Application.ThreadException
- 在通過訊息迴圈呼叫的程式的任何部分發生未處理的異常後,將觸發這些異常。
- 但是非UI執行緒上的未處理異常,並不會觸發它。
- 而任何執行緒有任何未處理的異常都會觸發
- AppDomain.CurrentDomain.UnhandledException
P6 前臺執行緒 vs 後臺執行緒
Foreground vs Background Threads
- 預設情況下,手動建立的執行緒就是前臺執行緒。
- 只要有前臺執行緒在執行,那麼應用程式就會一直處於活動狀態。
- 但是後臺執行緒卻不行。
- 一旦所有的前臺執行緒停止,那麼應用程式就停止了。
- 任何的後臺執行緒也會突然終止。
- 注意:執行緒的前臺、後臺狀態與它的優先順序無關(所分配的執行時間)。
- 可以通過IsBackground屬性判斷執行緒是否是後臺執行緒。
- 程序以這種形式終止的時候,後臺執行緒執行棧中的finally塊就不會被執行了。
- 如果想讓它執行,可以在退出程式時使用Join來等待後臺執行緒
- 應用程式無法正常退出的一個常見原因是還有活躍的前臺執行緒。
P7 執行緒優先順序
執行緒優先順序
- 執行緒的優先順序(Thread的Priority屬性)決定了相對於作業系統中其它活躍執行緒所佔的執行時間。
- 優先順序分為:
- enum ThreadPriority{Lowest, BelowNormal, Normal, AboveNormal, Highest}
提升執行緒優先順序
-
提升執行緒優先順序的時候需特別注意,因為它可能“餓死”其它執行緒。
-
如果想讓某執行緒(Thread)的優先順序比其它程序(Process)中的執行緒(Thread)高,那就必須提升程序(Process)的優先順序。
-
使用System.Diagnostics下的Process類。
using (Process p = Process.GetCurrentProcess()) { p.PriorityClass = ProcessPriorityClass.High; }
-
-
這可以很好地用於只做少量工作且需要較低延遲的非UI程序。
-
對於需要大量計算的應用程式(尤其是有UI的應用程式),提高程序優先順序可能會使其它程序餓死,從而降低整個計算機的速度。
P8 訊號介紹
訊號 Signaling
- 有時,需要讓某個執行緒一直處於等待的狀態,直至接收到其它執行緒發來的通知。這就叫做signaling(傳送訊號)。
- 最簡單的訊號結構就是ManualResetEvent。
- 呼叫它上面的WaitOne方法會阻塞當前的執行緒,直到另一個執行緒通過呼叫Set方法來開啟訊號。
- 呼叫完Set之後,訊號會處於“開啟”的狀態。可以通過呼叫Reset方法將其再次關閉。
P9 富客戶端應用處理耗時操作的一種辦法
富客戶端應用程式的執行緒
- 在WPF、UWP、WinForm等型別的程式中,如果在主執行緒執行耗時的操作,就會導致整個程式無響應。因為主執行緒同時還需要處理訊息迴圈,而渲染和滑鼠鍵盤事件處理等工作都是訊息迴圈來執行的。
- 針對這種耗時的操作,一種流行的做法是啟用一個worker執行緒。
- 執行完操作後,再更新到UI
- 富客戶端應用的執行緒模型通常是:
- UI元素和控制元件只能從建立它們的執行緒來進行訪問(通常是主UI執行緒)。
- 當想從worker執行緒更新UI的時候,必須把請求交給UI執行緒。
- 比較底層的實現是:
- WPF,在元素的Dispatcher物件上呼叫BeginInvoke或Invoke。
- WinForm,呼叫控制元件的BeginInvoke或Invoke。
- UWP,呼叫Dispatcher物件上的RunAsync或Invoke。
- 所有這些方法都接收一個委託。
- BeginInvoke或RunAsync通過將委託排隊到UI執行緒的訊息佇列來執行工作。
- Invoke執行相同的操作,但隨後會進行阻塞,直到UI執行緒讀取並處理訊息。
- 因此,Invoke允許從方法中獲取返回值。
- 如果不需要返回值,BeginInvoke/RunAsync更可取,因為它不會阻塞呼叫方法,也不會引入死鎖的可能性。
P10 Synchronization Context
Synchronization Contexts 同步上下文
- 在System.Threading;下有一個類:SynchronizationContext,它使得Thread Marshaling得到泛化。
- 針對移動、桌面(WPF、UWP,WinForms)等富客戶端應用的API,它們都定義和示例化了SynchronizationContext的子類。
- 可以通過靜態屬性SynchronizationContext.Current來獲得(當執行在UI執行緒時)
- 捕獲該屬性讓你可以在稍後的時候從worker執行緒向UI執行緒傳送資料。
- 呼叫Post就相當於呼叫Dispatch或Control上面的BeginInvoke方法。
- 還有一個Send方法,它等價於Invoke方法。
P11 執行緒池
執行緒池 Thread Pool
- 當開始一個執行緒的時候,將花費幾百微秒來組織型別一下的內容:
- 一個新的區域性變數棧(Stack)
- 執行緒池就可以節省這種開銷:
- 通過預先建立一個可迴圈使用執行緒的池來減少這一開銷。
- 執行緒池對於高效的並行程式設計和細粒度併發是必不可少的。
- 它允許在不被執行緒啟動的開銷淹沒的情況下執行短期操作。
使用執行緒池需要注意的幾點
- 不可以設定執行緒的Name
- 池執行緒都是後臺執行緒
- 阻塞池執行緒可使效能降級
- 你可以自由的更改池執行緒的優先順序
- 當它釋放回池的時候優先順序將還原為正常狀態
- 可以通過Thread.CurrentThread.IsThreadPoolThread屬性來判斷是否執行在池執行緒上。
進入執行緒池
- 最簡單的、顯式的在池執行緒執行程式碼的方式就是使用Task.Run
誰使用了執行緒池
- WCF、Remoting、ASP.Net、ASMX Web Services應用伺服器
- System.Timers.Timer、System.Threading.Timer
- 並行程式設計結構
- BackgroundWorker類(現在很多餘)
- 非同步委託(現在很多餘)
執行緒池中的整潔
- 執行緒池提供了另一個功能,即確保臨時超出計算-Bound的工作不會導致CPU超額訂閱。
- CPU超額訂閱:活躍的執行緒超過CPU的核數,作業系統就需要對執行緒進行時間切片。
- 超額訂閱對效能影響很大,時間切片需要昂貴的上下文切換,並且可能使CPU快取失效,而CPU快取對於現代處理器的效能至關重要。
CLR的策略
- CLR通過對任務排隊並對其啟動進行節流限制來避免執行緒池中的超額訂閱。
- 它首先執行儘可能多的併發任務(只要還有CPU核),然後通過爬山演算法調整併發級別,並在特定方向上不斷調整工作負載。
- 如果吞吐量提高,它將繼續朝同一方向(否則將反轉)。
- 這確保它始終追隨最佳效能曲線,即使面對計算機上競爭的程序活動時也是如此。
- 如果下面兩點能滿足,那麼CLR的策略將發揮出最佳效果:
- 工作項大多是短時間執行的(<250毫秒,或者理想情況下<100毫秒),因此CLR有很多機會進行測量和調整。
- 大部分時間都被阻塞的工作項不會主宰執行緒池。
- 如果想充分利用CPU,那麼保持執行緒池的“整潔”是非常重要的。
P12 開始一個Task
Thread的問題
- 執行緒(Thread)是用來建立併發(concurrency)的一種低級別工具,它有一些限制,尤其是:
- 雖然開始執行緒的時候可以方便的傳入資料,但是當Join的時候,很難從執行緒獲得返回值。
- 可能需要設定一些共享欄位。
- 如果操作丟擲異常,捕獲和傳播該異常都很麻煩。
- 無法告訴執行緒在結束時開始做另外的工作,你必須進行Join操作(在程序中阻塞當前的執行緒)
- 很難使用較小的併發(concurrent)來組建大型的併發。
- 導致了對手動同步的更大依賴以及隨之而來的問題。
- 雖然開始執行緒的時候可以方便的傳入資料,但是當Join的時候,很難從執行緒獲得返回值。
Task Class
- Task類可以很好的解決上述問題
- Task是一個相對高階的抽象:它代表了一個併發操作(concurrent)
- 該操作可能由Thread支援,或不由Thread支援
- Task是可組合的(可使用Continuation把他們串成鏈)
- Task可以使用執行緒池來減少啟動延遲
- 使用TaskCompletionSource,Tasks可以利用回撥的方式,在等待I/O繫結操作時完全避免執行緒。
開始一個Task
Task.Run
- Task類在System.Threading.Tasks名稱空間下。
- 開始一個Task最簡單的辦法就是使用Task.Run(.Net4.5,4.0的時候是Task.Factory.StartNew)這個靜態方法:
- 傳入一個Action委託即可。
- Task預設使用執行緒池,也就是後臺執行緒:
- 當主執行緒結束時,你建立的所有tasks都會結束。
- Task.Run返回一個Task物件,可以使用它來監視其過程
- 在Task.Run之後,我們沒有呼叫Start,因為該方法建立的是“熱”任務(hot task)
- 可以通過Task的建構函式建立“冷”任務(cold task),但是很少這樣做。
- 在Task.Run之後,我們沒有呼叫Start,因為該方法建立的是“熱”任務(hot task)
- 可以通過Task的Status屬性來跟蹤task的執行狀態。
Wait 等待
- 呼叫task的Wait方法會進行阻塞直到操作完成。
- 相當於呼叫thread上的Join方法。
- Wait也可以讓你指定一個超時時間和一個取消令牌來提前結束等待。
Long-running tasks 長時間執行的任務
-
預設情況下,CLR線上程池中執行Task,這非常適合短時間執行的Compute-Bound類工作。
-
針對長時間執行的任務或者阻塞操作,你可以不採用執行緒池。
Task task = Task.Factory.StartNew(() => { }, TaskCreationOptions.LongRunning);
-
如果同時執行多個long-running tasks(尤其是其中有處於阻塞狀態的),那麼效能將會受很大影響,這時有比TaskCreationOptions.LongRunning更好的辦法:
- 如果任務是IO-Bound,TaskCompletionSource和非同步函式可以讓你用回撥(Coninuations)代替執行緒來實現併發。
- 如果任務是Compute-Bound,生產者/消費者佇列允許你對任務的併發性進行限流,避免把其它執行緒和程序餓死。
P13 Task的返回值
Task的返回值
- Task有一個泛型子類叫做Task
,它允許發出一個返回值。 - 使用Func
委託或相容的Lambda表示式來呼叫Task.Run就可以得到Task . - 隨後,可以通過Result屬性來獲取返回的結果。
- 如果這個Task還沒有完成操作,訪問Result屬性會阻塞該執行緒直到task完成操作。
- Task
可以看作是一種所謂的“未來/許諾”(future、promise),在它裡面包裹著一個Reuslt,在稍後的時候就會變得可用。
P14 Task的異常
Task異常
- 與Thread不一樣,Task可以很方便的傳播異常
- 如果你的Task裡面丟擲了一個未處理的異常(故障),那麼該異常就會重新被丟擲給:
- 呼叫了Wait()的地方
- 訪問了Task
的Result屬性的地方。
- 如果你的Task裡面丟擲了一個未處理的異常(故障),那麼該異常就會重新被丟擲給:
- CLR將異常包裹在AggregateException裡,以便在並行程式設計場景中發揮很好的作用。
- 無需重新丟擲異常,通過Task的IsFaulted和IsCanceled屬性也可以檢測出Task是否發生了故障:
- 如果兩個屬性都返回false,那麼就沒有錯誤發生。
- 如果IsCanceled為True,那就說明一個OperationCanceledException為該Task丟擲了。
- 如果IsFaulted為true,那就說明另一個型別的異常被丟擲了,而Exception屬性也將指明錯誤。
異常與“自治”的Task
- 自治的,“設定完就不管了”的Task。就是指不通過呼叫Wait()方法、Result屬性或continuation進行回合的任務。
- 針對自治的Task,需要像Thread一樣,顯式的處理異常,避免發生“悄無聲息的故障”。
- 自治Task上未處理的異常稱為未觀察到的異常。
未觀察到的異常
- 可以通過全域性的TaskScheduler.UnobservedTaskException來訂閱未觀察到的異常
- 關於什麼是“未觀察到的異常”,有一些細微的差別:
- 使用超時進行等待的Task,如果在超時後發生故障,那麼它將會產生一個“未觀察到的異常”
- 在Task發生故障後,如果訪問Task的Exception屬性,那麼該異常就被認為是“已觀察到的”。