C#如何優(yōu)雅地取消進(jìn)程的執(zhí)行之Cancellation詳解
概述
從.NET Framework 4開始,.NET使用統(tǒng)一的模型來協(xié)作取消異步或長(zhǎng)時(shí)間運(yùn)行的同步線程。該模型基于一個(gè)稱為CancellationToken的輕量級(jí)對(duì)象。這個(gè)對(duì)象在調(diào)用一個(gè)或多個(gè)取消線程時(shí)(例如通過創(chuàng)建新線程或任務(wù)),是通過將token傳遞給每個(gè)線程來完成的(通過鏈?zhǔn)降姆绞揭来蝹鬟f)。單個(gè)線程能夠依次地將token的副本傳遞給其他線程。
之后,在適當(dāng)?shù)哪硞€(gè)時(shí)機(jī),創(chuàng)建token的對(duì)象就可以使用token來請(qǐng)求線程停止。只有請(qǐng)求對(duì)象可以發(fā)出取消請(qǐng)求,每個(gè)監(jiān)聽器負(fù)責(zé)監(jiān)聽到請(qǐng)求并以適當(dāng)和及時(shí)的方式響應(yīng)取消請(qǐng)求。
實(shí)現(xiàn)協(xié)作取消模型的一般模式是:
- 1、實(shí)例化一個(gè)CancellationTokenSource對(duì)象,該對(duì)象管理cancellation并將cancellation通知發(fā)送給單獨(dú)的cancellation token。
- 2、CancellationTokenSource對(duì)象的Token屬性,可以返回一個(gè)Token對(duì)象,我們可以將該Token對(duì)象發(fā)送給每個(gè)監(jiān)聽該cancellation的進(jìn)程或Task。
- 3、為每個(gè)任務(wù)或線程提供響應(yīng)取消的機(jī)制。
- 4、調(diào)用 CancellationTokenSource.Cancel() 方法,來取消線程或者Task。
【tips】我們?cè)谑褂胏ancellation的token取消線程后,應(yīng)該確保調(diào)用CancellationTokenSource.Dispose()方法,以便于釋放它持有的任何非托管資源。。
下圖展示出了CancellationTokenSource對(duì)象里的Token屬性對(duì)象,是如何傳遞到其他的線程里的。
合作取消模型使創(chuàng)建取消感知的應(yīng)用程序和庫(kù)變得更容易,它支持以下功能:
- 1、取消是合式的,不會(huì)強(qiáng)加給監(jiān)聽器。監(jiān)聽器確定如何優(yōu)雅地終止以響應(yīng)取消請(qǐng)求。
- 2、請(qǐng)求不同于監(jiān)聽。調(diào)用可取消的線程的對(duì)象,可以控制何時(shí)(如果有的話)取消被請(qǐng)求。
- 3、請(qǐng)求的對(duì)象,可以通過僅使用一個(gè)方法,即可發(fā)送取消請(qǐng)求到所有的token副本中。
- 4、監(jiān)聽器可以通過將多個(gè)Token連接成一個(gè)linked Token,來同時(shí)監(jiān)聽多個(gè)Token。
- 5、用戶代碼可以注意到并響應(yīng)library code的取消請(qǐng)求,而library code可以注意到并響應(yīng)用戶代碼的取消請(qǐng)求。
- 6、可以通過輪詢、回調(diào)注冊(cè)或等待等待句柄的方式,來通知監(jiān)聽器執(zhí)行取消請(qǐng)求。
與取消線程相關(guān)的類型
取消框架是作為一組相關(guān)類型實(shí)現(xiàn)的,這些類型在下表中列出。
CancellationTokenSource | 該對(duì)象創(chuàng)建cancellation token,并向 cancellation token的所有副本分發(fā)取消請(qǐng)求。 |
CancellationToken | 傳遞給一個(gè)或多個(gè)監(jiān)聽器的輕量級(jí)的值類型,通常作為方法參數(shù)。偵聽器通過輪詢、回調(diào)或等待句柄監(jiān)視token的IsCancellationRequested屬性的值。 |
OperationCanceledException | 此異常構(gòu)造函數(shù)的重載,接受CancellationToken作為參數(shù)。偵聽器可以選擇性地拋出此異常以驗(yàn)證取消的來源,并通知其他已響應(yīng)取消請(qǐng)求監(jiān)聽器。 |
取消模型以幾種類型集成到.net中。
最重要的是System.Threading.Tasks.Parallel,System.Threading.Tasks.Task、System.Threading.Tasks.Task<TResult> 和 System.Linq.ParallelEnumerable。
建議使用所有新的庫(kù)和應(yīng)用代碼來實(shí)現(xiàn)合作市取消模式。
代碼舉例
在下面的示例中,請(qǐng)求對(duì)象創(chuàng)建一個(gè)CancellationTokenSource對(duì)象,然后將該對(duì)象的Token屬性傳遞給可取消的進(jìn)程。
接收請(qǐng)求的線程通過輪詢來監(jiān)視Token的IsCancellationRequested屬性的值。
當(dāng)該值變?yōu)閠rue時(shí),偵聽器可以以任何合適的方式終止。在本例中,方法只是退出,這是許多情況下所需要的全部?jī)?nèi)容。
using System; using System.Threading; public class Example { public static void Main() { // Create the token source. CancellationTokenSource cts = new CancellationTokenSource(); // Pass the token to the cancelable operation. ThreadPool.QueueUserWorkItem(new WaitCallback(DoSomeWork), cts.Token); Thread.Sleep(2500); // Request cancellation. cts.Cancel(); Console.WriteLine("Cancellation set in token source..."); Thread.Sleep(2500); // Cancellation should have happened, so call Dispose. cts.Dispose(); } // Thread 2: The listener static void DoSomeWork(object? obj) { if (obj is null) return; CancellationToken token = (CancellationToken)obj; for (int i = 0; i < 100000; i++) { if (token.IsCancellationRequested) { Console.WriteLine("In iteration {0}, cancellation has been requested...", i + 1); // Perform cleanup if necessary. //... // Terminate the operation. break; } // Simulate some work. Thread.SpinWait(500000); } } } // The example displays output like the following: // Cancellation set in token source... // In iteration 1430, cancellation has been requested...
操作取消vs對(duì)象取消
在協(xié)作取消框架中,取消指的是操作(線程中執(zhí)行的操作),而不是對(duì)象。取消請(qǐng)求意味著在執(zhí)行任何所需的清理后,操作應(yīng)盡快停止。一個(gè)cancellation token應(yīng)該指向一個(gè)“可取消的操作”,無論該操作如何在您的程序中實(shí)現(xiàn)。
在token的IsCancellationRequested屬性被設(shè)置為true之后,它不能被重置為false。因此,取消令牌在被取消后不能被重用。
如果您需要對(duì)象取消機(jī)制,您可以通過調(diào)用CancellationToken來基于操作取消機(jī)制。注冊(cè)方法,如下例所示。
using System; using System.Threading; class CancelableObject { public string id; public CancelableObject(string id) { this.id = id; } public void Cancel() { Console.WriteLine("Object {0} Cancel callback", id); // Perform object cancellation here. } } public class Example1 { public static void Main() { CancellationTokenSource cts = new CancellationTokenSource(); CancellationToken token = cts.Token; // User defined Class with its own method for cancellation var obj1 = new CancelableObject("1"); var obj2 = new CancelableObject("2"); var obj3 = new CancelableObject("3"); // Register the object's cancel method with the token's // cancellation request. token.Register(() => obj1.Cancel()); token.Register(() => obj2.Cancel()); token.Register(() => obj3.Cancel()); // Request cancellation on the token. cts.Cancel(); // Call Dispose when we're done with the CancellationTokenSource. cts.Dispose(); } } // The example displays the following output: // Object 3 Cancel callback // Object 2 Cancel callback // Object 1 Cancel callback
如果一個(gè)對(duì)象支持多個(gè)并發(fā)的可取消操作,則可以給每個(gè)不同的可取消操作各自傳入一個(gè)不同的token。這樣,一個(gè)操作可以被取消而不會(huì)影響到其他操作。
監(jiān)聽并響應(yīng)取消請(qǐng)求
在用戶委托中,可取消操作的實(shí)現(xiàn)者決定如何終止該操作以響應(yīng)取消請(qǐng)求。在許多情況下,用戶委托可以只執(zhí)行任何所需的清理,然后立即返回。
但是,在更復(fù)雜的情況下,可能需要用戶委托通知庫(kù)代碼已發(fā)生cancellation。在這種情況下,終止操作的正確方法是委托調(diào)用ThrowIfCancellationRequested方法,這將導(dǎo)致拋出OperationCanceledException異常。庫(kù)代碼可以在用戶委托線程上捕獲此異常,并檢查異常的token,以確定該異常是否表示協(xié)作取消或其他異常情況。
在這種情況下,終止操作的正確方法是委托調(diào)用ThrowIfCancellationRequested方法,這將導(dǎo)致拋出OperationCanceledException。庫(kù)代碼可以在用戶委托線程上捕獲此異常,并檢查異常的token,以確定該異常是否表示協(xié)作取消或其他異常情況。
輪詢監(jiān)聽
對(duì)于循環(huán)或遞歸的長(zhǎng)時(shí)間運(yùn)行的計(jì)算,可以通過定期輪詢CancellationToken.IsCancellationRequested的值來監(jiān)聽取消請(qǐng)求。如果它的值為true,則該方法應(yīng)該盡快清理并終止。輪詢的最佳頻率取決于應(yīng)用程序的類型。開發(fā)人員可以為任何給定的程序確定最佳輪詢頻率。輪詢本身不會(huì)顯著影響性能。
下面的程序案例展示了一種可能的輪詢方式。
static void NestedLoops(Rectangle rect, CancellationToken token) { for (int col = 0; col < rect.columns && !token.IsCancellationRequested; col++) { // Assume that we know that the inner loop is very fast. // Therefore, polling once per column in the outer loop condition // is sufficient. for (int row = 0; row < rect.rows; row++) { // Simulating work. Thread.SpinWait(5_000); Console.Write("{0},{1} ", col, row); } } if (token.IsCancellationRequested) { // Cleanup or undo here if necessary... Console.WriteLine("\r\nOperation canceled"); Console.WriteLine("Press any key to exit."); // If using Task: // token.ThrowIfCancellationRequested(); } }
下面的程序代碼是一個(gè)詳細(xì)的實(shí)現(xiàn):
using System; using System.Threading; public class ServerClass { public static void StaticMethod(object obj) { CancellationToken ct = (CancellationToken)obj; Console.WriteLine("ServerClass.StaticMethod is running on another thread."); // Simulate work that can be canceled. while (!ct.IsCancellationRequested) { Thread.SpinWait(50000); } Console.WriteLine("The worker thread has been canceled. Press any key to exit."); Console.ReadKey(true); } } public class Simple { public static void Main() { // The Simple class controls access to the token source. CancellationTokenSource cts = new CancellationTokenSource(); Console.WriteLine("Press 'C' to terminate the application...\n"); // Allow the UI thread to capture the token source, so that it // can issue the cancel command. Thread t1 = new Thread(() => { if (Console.ReadKey(true).KeyChar.ToString().ToUpperInvariant() == "C") cts.Cancel(); } ); // ServerClass sees only the token, not the token source. Thread t2 = new Thread(new ParameterizedThreadStart(ServerClass.StaticMethod)); // Start the UI thread. t1.Start(); // Start the worker thread and pass it the token. t2.Start(cts.Token); t2.Join(); cts.Dispose(); } } // The example displays the following output: // Press 'C' to terminate the application... // // ServerClass.StaticMethod is running on another thread. // The worker thread has been canceled. Press any key to exit.
通過回調(diào)注冊(cè)進(jìn)行監(jiān)聽
以這種方式進(jìn)行的某些操作可能會(huì)阻塞,從而無法及時(shí)檢查cancellation token的值。對(duì)于這些情況,您可以注冊(cè)一個(gè)回調(diào)方法,以便在收到取消請(qǐng)求時(shí)解除對(duì)該方法的阻塞。
Register方法返回一個(gè)專門用于此目的的CancellationTokenRegistration對(duì)象。下面的示例展示了如何使用Register方法來取消異步Web請(qǐng)求。
using System; using System.Net; using System.Threading; class Example4 { static void Main() { CancellationTokenSource cts = new CancellationTokenSource(); StartWebRequest(cts.Token); // cancellation will cause the web // request to be cancelled cts.Cancel(); } static void StartWebRequest(CancellationToken token) { WebClient wc = new WebClient(); wc.DownloadStringCompleted += (s, e) => Console.WriteLine("Request completed."); // Cancellation on the token will // call CancelAsync on the WebClient. token.Register(() => { wc.CancelAsync(); Console.WriteLine("Request cancelled!"); }); Console.WriteLine("Starting request."); wc.DownloadStringAsync(new Uri("http://www.contoso.com")); } }
CancellationTokenRegistration對(duì)象管理線程同步,并確?;卣{(diào)將在精確的時(shí)間點(diǎn)停止執(zhí)行。
為了確保系統(tǒng)響應(yīng)性并避免死鎖,在注冊(cè)回調(diào)時(shí)必須遵循以下準(zhǔn)則:
1、回調(diào)方法應(yīng)該是快速的,因?yàn)樗峭秸{(diào)用的,因此對(duì)Cancel的調(diào)用在回調(diào)返回之前不會(huì)返回。
2、如果在回調(diào)運(yùn)行時(shí)調(diào)用Dispose,并且持有回調(diào)等待的鎖,則程序可能會(huì)死鎖。Dispose返回后,您可以釋放回調(diào)所需的任何資源。
3、Callbacks 不應(yīng)該執(zhí)行任何手動(dòng)線程或在回調(diào)中使用SynchronizationContext。如果回調(diào)必須在特定線程上運(yùn)行,則使用System.Threading.CancellationTokenRegistration構(gòu)造函數(shù),該構(gòu)造函數(shù)使您能夠指定目標(biāo)syncContext是活動(dòng)的SynchronizationContext.Current。在回調(diào)中執(zhí)行手動(dòng)線程會(huì)導(dǎo)致死鎖。
使用WaitHandle進(jìn)行偵聽
當(dāng)一個(gè)可取消的操作在等待一個(gè)同步原語(如System.Threading. manualresetevent或System.Threading. Semaphore)時(shí)可能會(huì)阻塞。
你可以使用CancellationToken.WaitHandle屬性,以使操作同時(shí)等待事件和取消請(qǐng)求。
CancellationToken的 等待句柄 將在響應(yīng)取消請(qǐng)求時(shí)發(fā)出信號(hào),該方法可以使用WaitAny()方法的返回值來確定發(fā)出信號(hào)的是否是cancellation token。然后操作可以直接退出,或者拋出OperationCanceledException異常。
// Wait on the event if it is not signaled. int eventThatSignaledIndex = WaitHandle.WaitAny(new WaitHandle[] { mre, token.WaitHandle }, new TimeSpan(0, 0, 20));
System.Threading.ManualResetEventSlim和System.Threading.SemaphoreSlim都在它們的Wait()方法中支持取消框架。
您可以將CancellationToken傳遞給該方法,當(dāng)請(qǐng)求取消時(shí),事件將被喚醒并拋出OperationCanceledException。
try { // mres is a ManualResetEventSlim mres.Wait(token); } catch (OperationCanceledException) { // Throw immediately to be responsive. The // alternative is to do one more item of work, // and throw on next iteration, because // IsCancellationRequested will be true. Console.WriteLine("The wait operation was canceled."); throw; } Console.Write("Working..."); // Simulating work. Thread.SpinWait(500000);
下面的示例使用ManualResetEvent來演示如何解除阻塞不支持統(tǒng)一取消的等待句柄。
using System; using System.Threading; using System.Threading.Tasks; class CancelOldStyleEvents { // Old-style MRE that doesn't support unified cancellation. static ManualResetEvent mre = new ManualResetEvent(false); static void Main() { var cts = new CancellationTokenSource(); // Pass the same token source to the delegate and to the task instance. Task.Run(() => DoWork(cts.Token), cts.Token); Console.WriteLine("Press s to start/restart, p to pause, or c to cancel."); Console.WriteLine("Or any other key to exit."); // Old-style UI thread. bool goAgain = true; while (goAgain) { char ch = Console.ReadKey(true).KeyChar; switch (ch) { case 'c': cts.Cancel(); break; case 'p': mre.Reset(); break; case 's': mre.Set(); break; default: goAgain = false; break; } Thread.Sleep(100); } cts.Dispose(); } static void DoWork(CancellationToken token) { while (true) { // Wait on the event if it is not signaled. int eventThatSignaledIndex = WaitHandle.WaitAny(new WaitHandle[] { mre, token.WaitHandle }, new TimeSpan(0, 0, 20)); // Were we canceled while waiting? if (eventThatSignaledIndex == 1) { Console.WriteLine("The wait operation was canceled."); throw new OperationCanceledException(token); } // Were we canceled while running? else if (token.IsCancellationRequested) { Console.WriteLine("I was canceled while running."); token.ThrowIfCancellationRequested(); } // Did we time out? else if (eventThatSignaledIndex == WaitHandle.WaitTimeout) { Console.WriteLine("I timed out."); break; } else { Console.Write("Working... "); // Simulating work. Thread.SpinWait(5000000); } } } }
下面的示例使用ManualResetEventSlim來演示如何解除支持統(tǒng)一取消的協(xié)調(diào)原語的阻塞。同樣的方法也可以用于其他輕量級(jí)協(xié)調(diào)原語,如SemaphoreSlim和CountdownEvent。
using System; using System.Threading; using System.Threading.Tasks; class CancelNewStyleEvents { // New-style MRESlim that supports unified cancellation // in its Wait methods. static ManualResetEventSlim mres = new ManualResetEventSlim(false); static void Main() { var cts = new CancellationTokenSource(); // Pass the same token source to the delegate and to the task instance. Task.Run(() => DoWork(cts.Token), cts.Token); Console.WriteLine("Press c to cancel, p to pause, or s to start/restart,"); Console.WriteLine("or any other key to exit."); // New-style UI thread. bool goAgain = true; while (goAgain) { char ch = Console.ReadKey(true).KeyChar; switch (ch) { case 'c': // Token can only be canceled once. cts.Cancel(); break; case 'p': mres.Reset(); break; case 's': mres.Set(); break; default: goAgain = false; break; } Thread.Sleep(100); } cts.Dispose(); } static void DoWork(CancellationToken token) { while (true) { if (token.IsCancellationRequested) { Console.WriteLine("Canceled while running."); token.ThrowIfCancellationRequested(); } // Wait on the event to be signaled // or the token to be canceled, // whichever comes first. The token // will throw an exception if it is canceled // while the thread is waiting on the event. try { // mres is a ManualResetEventSlim mres.Wait(token); } catch (OperationCanceledException) { // Throw immediately to be responsive. The // alternative is to do one more item of work, // and throw on next iteration, because // IsCancellationRequested will be true. Console.WriteLine("The wait operation was canceled."); throw; } Console.Write("Working..."); // Simulating work. Thread.SpinWait(500000); } } }
同時(shí)監(jiān)聽多個(gè)令牌
在某些情況下,偵聽器必須同時(shí)偵聽多個(gè)cancellation token。
例如,一個(gè)可取消操作除了監(jiān)控通過方法形參傳入的外部token之外,還可能必須監(jiān)視內(nèi)部的cancellation token。為此,創(chuàng)建一個(gè)linked token源,它可以將兩個(gè)或多個(gè)token連接到一個(gè)token中,如下面的示例所示。
using System; using System.Threading; using System.Threading.Tasks; class LinkedTokenSourceDemo { static void Main() { WorkerWithTimer worker = new WorkerWithTimer(); CancellationTokenSource cts = new CancellationTokenSource(); // Task for UI thread, so we can call Task.Wait wait on the main thread. Task.Run(() => { Console.WriteLine("Press 'c' to cancel within 3 seconds after work begins."); Console.WriteLine("Or let the task time out by doing nothing."); if (Console.ReadKey(true).KeyChar == 'c') cts.Cancel(); }); // Let the user read the UI message. Thread.Sleep(1000); // Start the worker task. Task task = Task.Run(() => worker.DoWork(cts.Token), cts.Token); try { task.Wait(cts.Token); } catch (OperationCanceledException e) { if (e.CancellationToken == cts.Token) Console.WriteLine("Canceled from UI thread throwing OCE."); } catch (AggregateException ae) { Console.WriteLine("AggregateException caught: " + ae.InnerException); foreach (var inner in ae.InnerExceptions) { Console.WriteLine(inner.Message + inner.Source); } } Console.WriteLine("Press any key to exit."); Console.ReadKey(); cts.Dispose(); } } class WorkerWithTimer { CancellationTokenSource internalTokenSource = new CancellationTokenSource(); CancellationToken internalToken; CancellationToken externalToken; Timer timer; public WorkerWithTimer() { // A toy cancellation trigger that times out after 3 seconds // if the user does not press 'c'. timer = new Timer(new TimerCallback(CancelAfterTimeout), null, 3000, 3000); } public void DoWork(CancellationToken externalToken) { // Create a new token that combines the internal and external tokens. this.internalToken = internalTokenSource.Token; this.externalToken = externalToken; using (CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalToken, externalToken)) { try { DoWorkInternal(linkedCts.Token); } catch (OperationCanceledException) { if (internalToken.IsCancellationRequested) { Console.WriteLine("Operation timed out."); } else if (externalToken.IsCancellationRequested) { Console.WriteLine("Cancelling per user request."); externalToken.ThrowIfCancellationRequested(); } } } } private void DoWorkInternal(CancellationToken token) { for (int i = 0; i < 1000; i++) { if (token.IsCancellationRequested) { // We need to dispose the timer if cancellation // was requested by the external token. timer.Dispose(); // Throw the exception. token.ThrowIfCancellationRequested(); } // Simulating work. Thread.SpinWait(7500000); Console.Write("working... "); } } public void CancelAfterTimeout(object? state) { Console.WriteLine("\r\nTimer fired."); internalTokenSource.Cancel(); timer.Dispose(); } }
注意,當(dāng)您完成對(duì)鏈接的令牌源的處理后,必須對(duì)它調(diào)用Dispose。
當(dāng)linked token拋出一個(gè)操作消連時(shí),傳遞給異常的token就是linked token,而不是前任token。為了確定token的哪個(gè)被取消,請(qǐng)直接檢查前任token的狀態(tài)。
在本例中,AggregateException不應(yīng)該被拋出,但這里會(huì)捕獲它,因?yàn)樵趯?shí)際場(chǎng)景中,除了從任務(wù)委托拋出的OperationCanceledException之外,任何其他異常都被包裝在AggregateException中。
總結(jié)
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
C#傳值方式實(shí)現(xiàn)不同程序窗體間通信實(shí)例
Form2構(gòu)造函數(shù)中接收一個(gè)string類型參數(shù),即Form1中選中行的文本,將Form2的TextBox控件的Text設(shè)置為該string,即完成了Form1向Form2的傳值2013-12-12c# 給button添加不規(guī)則的圖片以及用pictureBox替代button響應(yīng)點(diǎn)擊事件的方法
這篇文章介紹了c# 給button添加不規(guī)則的圖片以及用pictureBox替代button響應(yīng)點(diǎn)擊事件的方法,有需要的朋友可以參考一下2013-09-09C#使用Socket實(shí)現(xiàn)發(fā)送和接收?qǐng)D片的方法
這篇文章主要介紹了C#使用Socket實(shí)現(xiàn)發(fā)送和接收?qǐng)D片的方法,涉及C#操作socket發(fā)送與接收文件的使用技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-04-04C# DoubleClick與MouseDoubleClick區(qū)別,雙擊事件引發(fā)順序
從邏輯上來說,由于比MouseDoubleClick 描述更抽象,DoubleClick 事件是控件的更高級(jí)別的事件2009-09-09C#實(shí)現(xiàn)自定義ListBox背景的示例詳解
這篇文章主要為大家詳細(xì)介紹了如何利用C#實(shí)現(xiàn)自定義ListBox背景,文中的示例代碼講解詳細(xì),對(duì)我們學(xué)習(xí)C#有一定的幫助,感興趣的小伙伴可以跟隨小編一起了解一下2022-12-12利用C#實(shí)現(xiàn)網(wǎng)絡(luò)爬蟲
這篇文章主要介紹了利用C#實(shí)現(xiàn)網(wǎng)絡(luò)爬蟲,完整的介紹了C#實(shí)現(xiàn)網(wǎng)絡(luò)爬蟲詳細(xì)過程,感興趣的小伙伴們可以參考一下2016-03-03C#實(shí)現(xiàn)合并多個(gè)word文檔的方法
這篇文章主要介紹了C#實(shí)現(xiàn)合并多個(gè)word文檔的方法,是C#針對(duì)Word文檔操作的一個(gè)非常重要的技巧,需要的朋友可以參考下2014-09-09采用C#實(shí)現(xiàn)軟件自動(dòng)更新的方法
這篇文章主要介紹了采用C#實(shí)現(xiàn)軟件自動(dòng)更新的方法,非常實(shí)用的功能,需要的朋友可以參考下2014-08-08