本篇同步发文於个人Blog: [读书笔记] Threading in C# - PART 3: USING THREADS
合作式取消模型
当Worker thread完成工作, 可安全更新WFP/Winform的UI元件
在完成的事件传递Exception
EAP只是个Pattern, 实作上最常见的有BackgroundWorker, WebClient等
这些Class包含有*Async的方法, 通常就是EAP. 呼叫*Async的方法, 将任务交给其他thread执行, 而任务完成後, 会触发Completed事件
*Completed事件的参数包含这些:
有个flag标示该任务是否有被取消
有exception抛出时, 包装在Error物件
call function代入的userToken
使用EAP的设计, 如果有遵循APM的规则, 可以节省Thread
之後的Task实作和EAP很相似, 让EAP的魅力大减
合作的取消模型
当Worker完成, 可以安全更新WPF/Winform的Control
把Exception传递到完成事件
有个进度回报的protocol
实作IComponent, 在Design time(Ex: Visual Studio Designer)可以被托管
建立BackgroundWorker的最小步骤: 建立BackgroundWorker并处理DoWork事件, 再呼叫RunWorkerAsync函式, 此函式也能代入参数. 在DoWork委托的函式, 从DoWorkEventArgs取出Argument, 代表有代入的参数.
以下是基本的范例
using System;
using System.ComponentModel;
namespace BackgroundWorkerTest
{
class Program
{
static BackgroundWorker _bw = new BackgroundWorker();
static void Main(string[] args)
{
_bw.DoWork += MyDoWork;
_bw.RunWorkerAsync(123456);
Console.ReadLine();
}
private static void MyDoWork(object sender, DoWorkEventArgs e)
{
Console.WriteLine(e.Argument);
}
}
}
BackgroundWorker有个RunWorkerCompleted事件, 当DoWork的事件完成将会触发, 而在RunWorkerCompleted里查询有DoWork抛出的Exception、也能对UI Control做更新
如果要增加progress reporting, 要以下步骤:
设置WorkerReportsProgress属性为true
定期在DoWork的委托事件呼叫ReportProgress, 代入目前完成的进度值, 也可选代入user-state
新增ProgressChanged事件处理, 查询前面代入的进度值, 用ProgressPercentage参数查
在ProgressChanged也能更新UI Control
设置WorkerSupportsCancellation属性为true
定期在DoWork的委托事件内检查CancellationPending这个boolean值, 如果它是true, 则可以设置DoWorkEventArgs的Cancel为true并做return. 如果DoWork的工作太困难而不能继续执行, 也可以不理会CancellationPending的状态而直接设Cancel为true
呼叫CancelAsync来请求取消任务
using System;
using System.ComponentModel;
using System.Threading;
namespace BackgroundWorkerProgressCancel
{
class Program
{
static BackgroundWorker _bw;
static void Main(string[] args)
{
_bw = new BackgroundWorker
{
WorkerReportsProgress = true,
WorkerSupportsCancellation = true
};
_bw.DoWork += bw_DoWork;
_bw.ProgressChanged += bw_ProgressChanged;
_bw.RunWorkerCompleted += bw_RunWorkerCompleted;
_bw.RunWorkerAsync("Run worker now");
Console.WriteLine("Press Enter in the next 5 seconds to cancel");
Console.ReadLine();
if (_bw.IsBusy)
{
_bw.CancelAsync();
}
Console.ReadLine();
}
private static void bw_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
if (e.Cancelled)
{
Console.WriteLine("You canceled");
}
else if(e.Error != null)
{
Console.WriteLine("Worker exception: " + e.Error.ToString());
}
else
{
Console.WriteLine("Completed: " + e.Result);
}
}
private static void bw_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
Console.WriteLine("Reached " + e.ProgressPercentage + "%");
}
private static void bw_DoWork(object sender, DoWorkEventArgs e)
{
for(int i = 0; i <= 100; i+= 20)
{
if (_bw.CancellationPending)
{
e.Cancel = true;
return;
}
_bw.ReportProgress(i);
Thread.Sleep(1000);
}
e.Result = 123456;
}
}
}
可以继承BackgroundWorker来实作EAP
以下范例是整合前面BackgroundWorker的范例, 再搭配原作者未完整的继承案例, 功能是每一秒会累加财务的金额和信用点数, 增加的值是建构物件时给的参数. 经过5秒後把累加的结果放在Dictionary
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading;
namespace BackgroundWorkerSubClass
{
class Program
{
static FinancialWorker _bw;
static void Main(string[] args)
{
_bw = new Client().GetFinancialTotalsBackground(10, 50);
_bw.ProgressChanged += bw_ProgressChanged;
_bw.RunWorkerCompleted += bw_RunWorkerCompleted;
_bw.RunWorkerAsync("Hello to worker");
Console.WriteLine("Press Enter in the next 5 seconds to cancel");
Console.ReadLine();
if (_bw.IsBusy) _bw.CancelAsync();
Console.ReadLine();
}
static void bw_RunWorkerCompleted(object sender,
RunWorkerCompletedEventArgs e)
{
if (e.Cancelled)
Console.WriteLine("You canceled!");
else if (e.Error != null)
Console.WriteLine("Worker exception: " + e.Error.ToString());
else
{
Dictionary<string, int> result = e.Result as Dictionary<string, int>;
Console.WriteLine("Complete: "); // from DoWork
foreach (var item in result)
{
Console.WriteLine($"Key {item.Key} Value {item.Value}");
}
}
}
static void bw_ProgressChanged(object sender,
ProgressChangedEventArgs e)
{
Console.WriteLine("Reached " + e.ProgressPercentage + "%");
}
}
public class Client
{
public FinancialWorker GetFinancialTotalsBackground(int moneyIncreaseBase, int creditPointIncreaseBase)
{
return new FinancialWorker(moneyIncreaseBase, creditPointIncreaseBase);
}
}
public class FinancialWorker : BackgroundWorker
{
public Dictionary<string, int> Result; // You can add typed fields.
public readonly int MoneyIncreaseBase, CreditPointIncreaseBase;
public FinancialWorker()
{
WorkerReportsProgress = true;
WorkerSupportsCancellation = true;
}
public FinancialWorker(int moneyIncreaseBase, int creditPointIncreaseBase) : this()
{
this.MoneyIncreaseBase = moneyIncreaseBase;
this.CreditPointIncreaseBase = creditPointIncreaseBase;
}
protected override void OnDoWork(DoWorkEventArgs e)
{
Result = new Dictionary<string, int>();
Result.Add("Money", 0);
Result.Add("CreditPoint", 0);
int percentCompleteCalc = 0;
while (percentCompleteCalc <= 80)
{
if (CancellationPending)
{
e.Cancel = true;
return;
}
ReportProgress(percentCompleteCalc, "Monet & Credit Point is increasing!");
percentCompleteCalc += 20;
Result["Money"] += MoneyIncreaseBase;
Result["CreditPoint"] += CreditPointIncreaseBase;
Thread.Sleep(1000);
}
ReportProgress(100, "Done!");
e.Result = Result;
}
}
}
这种继承写法, 可以让Caller不用指定DoWork委托, 在呼叫RunWorkerAsync时执行有override的OnDoWork.
主要是把progress report, cancellation和comleted(可以更新UI之类、取运算结果)要负责的功能给caller指定, 而DoWork的逻辑交给该BackgroundWorker子类别负责.
Interrupt和Abort能停止Blocked的thread
Abort也能停止非block的thread, 比如一直在无限回圈执行的thread, 所以Abort会在特定场合使用, 但Interrupt很少用到
Interrupt能强制使blocked thread释放, 并抛出ThreadInterruptedException.
除非没有handle ThreadInterruptedException(Catch抓到它), 否则该thread在interrupt後不会结束.
using System;
using System.Threading;
namespace InterruptBasic
{
class Program
{
static void Main(string[] args)
{
Thread t = new Thread(() =>
{
try
{
Thread.Sleep(Timeout.Infinite);
}
catch (ThreadInterruptedException)
{
Console.WriteLine("Forcibly");
}
Console.WriteLine("Woken!");
});
t.Start();
t.Interrupt();
}
}
}
using System;
using System.Threading;
namespace ThreadInterruptNonblocking
{
class Program
{
static void Main(string[] args)
{
Thread t = new Thread(() =>
{
try
{
long count = 0;
while (count < 1000000000)
{
count++;
}
Console.WriteLine("Sleep");
Thread.Sleep(1000);
Console.WriteLine("I am done");
}
catch(ThreadInterruptedException ex)
{
Console.WriteLine("Catch interrupt!");
}
});
t.Start();
Console.WriteLine("Call interrupt");
t.Interrupt();
Console.ReadLine();
}
}
}
if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)
worker.Interrupt();
只要有thread在lock或synchronized的时候发生blocked, 则有对它interrupt的指令蜂拥而上. 如果该thread没有处理好发生interrupt後的後续(比如在finally要释放资源), 将导致资源不正确释放、物件状态未知化.
因此, interrupt是不必要的, 要强制对blocked thread做释放, 安全的方式是用cancellation token. 如果是要unblock thread, Abort相较是比较有用的.
Abort也是强制释放blocked thread, 且会抛出ThreadAbortException. 但是在catch结尾会再重抛一次该exception
如果有在catch呼叫Thread.ResetAbort, 就不会发生重抛
在呼叫Abort後的时间内, 该thread的ThreadState是AbortRequested
尚未handle的ThreadAbortException 并不会造成程序shutdown
Abort和Interrupt的最大差异在於被呼叫的non-blocked thread会发生什麽事. Interrupt会等到该thread block才运作, 而Abort会立即抛出exceptio(unmanaged code除外)
Managed code不是abort-safe, 比如有个FileStream在建构读档的过程被Abort, 而unmanaged的file handler没被中止, 导致档案一直open, 直到该程序的AppDomain结束才会释放.
有2个案例是可以安全做Abort:
在abort该thread後, 连它的AppDomain也要中止. 比如Unit testing
对自身Thread做Abort, 比如ASP.NET的Redirect机制是这样做
Abort在大部分的情境, 是个很危险的功能
建议替代的方式是实作cooperative pattern, 也就是worker会定期检查某个flag, 如果该flag被设立, 则自己做abort(比如BackgroundWorker)
Caller对该flag设置, Worker会定期检查到.
这种pattern的缺点是worker的method必须显式支援cancellation
这种是少数安全的cancellation pattern
以下是自定义封装的cancellation flag class:
using System;
using System.Threading;
namespace CancellationCustom
{
class Program
{
static void Main(string[] args)
{
var canceler = new RulyCanceler();
new Thread(()=>{
try{
Work(canceler);
}
catch(OperationCanceledException){
Console.WriteLine("Canceled");
}
}).Start();
Thread.Sleep(1000);
canceler.Cancel();
}
private static void Work(RulyCanceler canceler)
{
while(true)
{
canceler.ThrowIfCancellationRequested();
try
{
// other method
OtherMethod(canceler);
}
finally
{
// any required cleanup
}
}
}
private static void OtherMethod(RulyCanceler canceler)
{
// do stuff...
for(int i = 0 ; i < 1000000;++i)
{
}
Console.WriteLine("I am doing work");
canceler.ThrowIfCancellationRequested();
}
}
class RulyCanceler
{
object _cancelLocker = new object();
bool _cancelRequest;
public bool IsCancellationRequested
{
get
{
lock(_cancelLocker)
{
return _cancelRequest;
}
}
}
public void Cancel()
{
lock(_cancelLocker)
{
_cancelRequest = true;
}
}
public void ThrowIfCancellationRequested()
{
if(IsCancellationRequested)
{
throw new OperationCanceledException();
}
}
}
}
CancellationTokenSource提供Cancel方法
CancellationToken有IsCancellationRequested属性和ThrowIfCancellationRequested方法
这个类别是更前面范例更复杂, 拆出2个类别作分开的功能(Cancel和检查flag)
使用CancellationTokenSource范例如下:
using System;
using System.Threading;
namespace CancellationTokenCustom
{
class Program
{
static void Main(string[] args)
{
var cancelSource = new CancellationTokenSource();
new Thread(() => {
try
{
Work(cancelSource.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Canceled");
}
}).Start();
Thread.Sleep(1000);
cancelSource.Cancel();
Console.ReadLine();
}
private static void Work(CancellationToken cancelToken)
{
while (true)
{
cancelToken.ThrowIfCancellationRequested();
try
{
// other method
OtherMethod(cancelToken);
}
finally
{
// any required cleanup
}
}
}
private static void OtherMethod(CancellationToken cancelToken)
{
// do stuff...
for (int i = 0; i < 1000000; ++i)
{
}
Console.WriteLine("I am doing work");
cancelToken.ThrowIfCancellationRequested();
}
}
}
先建立CancellationTokenSource物件
将CancellationTokenSource的CancellationToken代入可支援取消的函式
在支援取消的函式, 不断用CancellationToken物件检查IsCancellationRequested或者透过ThrowIfCancellationRequested来中止程序
对CancellationTokenSource物件呼叫Cancel方法
CancellationToken是struct, 意味这如果有隐式copy给其他的token, 则都是参考同一个CancellationTokenSource
CancellationToken的WaitHandle属性会回传取消的讯号, 而Register方法可以注册一个委托事件, 当cancel被呼叫时可以触发该委托.
Cancellation tokens在Net Framework常用的类别如下:
ManualResetEventSlim and SemaphoreSlim
CountdownEvent
Barrier
BlockingCollection
PLINQ and Task Parallel Library
class Foo
{
public readonly Expensive Expensive = new Expensive();
}
class Expensive
{
// suppose this is expensive to construct
}
class Foo
{
Expensive _expensive;
public Expensive Expensive
{
get
{
if(_expensive == null)
{
_expensive = new Expensive();
}
return _expensive;
}
}
}
class Expensive
{
// suppose this is expensive to construct
}
class Foo
{
Expensive _expensive;
readonly object _expensiveLock = new object();
public Expensive Expensive
{
get
{
lock(_expensiveLock)
{
if(_expensive == null)
{
_expensive = new Expensive();
}
return _expensive;
}
}
}
}
class Expensive
{
// suppose this is expensive to construct
}
.NET Framework 4.0提供Lazy的类别, 能做lazy initialization的功能. Constructor有1个参数isThreadSafe, 设为true时, 代表能支援thread-safe, 若为false, 只能用在single-thread的情境.
Lazy在支援thread-safe的实作, 采用Double-checked locking, 更有效率检查初始化
改成用Lazy且是factory的写法:
class Foo
{
Lazy<Expensive> _expensive = new Lazy<Expensive>(() => new Expensive(), true);
readonly object _expensiveLock = new object();
public Expensive Expensive
{
get
{
return _expensive.Value;
}
}
}
它的static method可以直接对想做lazy initialization的field, 可以效能优化
有提供其他的初始化模式, 会有多个执行绪竞争
class Foo
{
Expensive _expensive;
public Expensive Expensive
{
get
{
LazyInitializer.EnsureInitialized(ref _expensive, () => new Expensive());
return _expensive;
}
}
}
可以传另一个参数做thread race的初始化, 最终只会有1个thread取得1个物件. 这种作法好处是比起Double-checked locking还要快, 因为它不需要lock.
但thread race的初始化很少会用到, 且它有一些缺点:
如果有多个thread竞争, 数量比CPU core还多, 会比较慢
潜在得浪费CPU资源做重复的初始化
初始化的逻辑必须是thread-safe, 比如前述Expensive的Constructor, 有static变数要写入的话, 就可能是thread-unsafe
initializer对物件的初始化需要dispose时, 而没有额外的逻辑就无法对浪费的物件做dispose
volatile Expensive _expensive;
public Expensive Expensive
{
get
{
if (_expensive == null) // First check (outside lock)
lock (_expenseLock)
if (_expensive == null) // Second check (inside lock)
_expensive = new Expensive();
return _expensive;
}
}
volatile Expensive _expensive;
public Expensive Expensive
{
get
{
if (_expensive == null)
{
var instance = new Expensive();
Interlocked.CompareExchange (ref _expensive, instance, null);
}
return _expensive;
}
}
Thread拥有自己独立的资料, 别的Thread无法存取
有3种thread-local storage的实作
对1个static的field加上ThreadStatic属性, 因此每个thread存取该变数都是独立的
缺点是不能用在instance的变数, 且它只有在第1个thread存取它时才初始化值一次, 因此其他thread一开始都拿到预设值.
以下范例是另外建2个thread对ThreadStatic变数_x各自修改值并输出. Static constructor在程序刚启动以Main Thread执行, 因此初始化的值5只有给Main Thread, 而t1和t2的_x值是0. 在Sleep 2秒後, Main thread的_x值仍是 5 .
using System;
using System.Threading;
namespace ThreadStaticTest
{
class Program
{
[ThreadStatic] static int _x;
static Program()
{
_x = 5;
}
static void Main(string[] args)
{
Thread t1 = new Thread(() => {
Console.WriteLine("t1 before: " + _x);
_x = 666;
Console.WriteLine("t1 after: " + _x);
});
Thread t2 = new Thread(() => {
Console.WriteLine("t2 before: " + _x);
_x = 777;
Console.WriteLine("t2 after: " + _x);
});
t1.Start();
t2.Start();
Thread.Sleep(2000);
Console.WriteLine(_x);
Console.ReadLine();
}
}
}
在Net Framework 4.0推出, 能对static 和 instance的field指定预设值
用ThreadLocal建立的值, 要存取时使用它的Value property
ThreadLocal有使用Lazy存取, 因此每个Thread再存取时会做Lazy的计算
如下面范例, 每个Thread的_x初始值都是3
using System;
using System.Threading;
namespace ThreadLocalTest
{
class Program
{
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
Thread t1 = new Thread(() => {
Console.WriteLine("t1 before: " + _x);
_x.Value = 666;
Console.WriteLine("t1 after: " + _x);
});
Thread t2 = new Thread(() => {
Console.WriteLine("t2 before: " + _x);
_x.Value = 777;
Console.WriteLine("t2 after: " + _x);
});
t1.Start();
t2.Start();
Thread.Sleep(2000);
Console.WriteLine(_x);
Console.ReadLine();
}
}
}
如果是建立instance field, 用Random作为范例, Random类别是thread-unsafe, 因此在multi-thread的环境使用lock之外, 可以用ThreadLocal建立属於各thread的独立物件, 如下范例:
var localRandom = new ThreadLocal(() => new Random());
Console.WriteLine (localRandom.Value.Next());
前面Random本身有小缺陷, 如果multi-thread在相差10ms之间都对Random取值, 可能会取到相同的值, 因此可以改良带入一些随机参数做初始化:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
把资料存在LocalDataStoreSlot, 而这slot可以设定名称或者不命名
由Thread的GetNamedDataSlot方法设定有名称的slot, 而AllocateDataSlot方法取得不命名的slot
Thread的FreeNamedDataSlot方法会释放有特定名称的slot与所有thread的关联, 但原本的slot物件仍可以存取该资料
以下范例是建立名称为Name的slot和不命名的slot, 分别是存字串MyName和整数值MyNum. Main Thread和另外建立的t1 t2 thread, 对MyName与MyNum都是独立的值. 最後在呼叫FreeNamedDataSlot之前, 从Name取slot的值仍是"Main name", 但呼叫FreeNamedDataSlot後, 从Name取slot的值变成null.
using System;
using System.Threading;
namespace TestLocalDataStoreSlot
{
class Program
{
static LocalDataStoreSlot _nameSlot = Thread.GetNamedDataSlot("Name");
static LocalDataStoreSlot _numSlot = Thread.AllocateDataSlot();
static string MyName
{
get
{
object data = Thread.GetData(_nameSlot);
return data == null ? string.Empty : (string)data;
}
set
{
Thread.SetData(_nameSlot, value);
}
}
static int MyNum
{
get
{
object data = Thread.GetData(_numSlot);
return data == null ? -1 : (int)data;
}
set
{
Thread.SetData(_numSlot, value);
}
}
static void Main(string[] args)
{
Thread t1 = new Thread(() =>
{
Console.WriteLine("t1 before name: " + MyName);
MyName = "T1!";
Console.WriteLine("t1 after name: " + MyName);
Console.WriteLine("t1 before num: " + MyNum);
MyNum = 555;
Console.WriteLine("t1 after num: " + MyNum);
});
Thread t2 = new Thread(() =>
{
Console.WriteLine("t2 before name: " + MyName);
MyName = "T2?";
Console.WriteLine("t2 after name: " + MyName);
Console.WriteLine("t2 before num: " + MyNum);
MyNum = 777;
Console.WriteLine("t2 after num: " + MyNum);
});
t1.Start();
t2.Start();
Console.WriteLine("Main before name: " + MyName);
MyName = "Main name";
Console.WriteLine("Main after name: " + MyName);
Console.WriteLine("Main before num: " + MyNum);
MyNum = 12345678;
Console.WriteLine("Main after num: " + MyNum);
Console.ReadLine();
string s1 = Thread.GetData(Thread.GetNamedDataSlot("Name")) as string;
Console.WriteLine("Main before clear: " + s1);
Thread.FreeNamedDataSlot("Name");
string s2 = Thread.GetData(Thread.GetNamedDataSlot("Name")) as string;
Console.WriteLine("Main after clear: " + s2);
Console.ReadLine();
}
}
}
Timer可提供某些工作做周期性的执行
在不用Timer的写法如下, 缺点是会绑住Thread的资源, 且DoSomeAction的任务将逐渐延迟执行
new Thread (delegate() {
while (enabled)
{
DoSomeAction();
Thread.Sleep (TimeSpan.FromHours (24));
}
}).Start();
System.Threading.Timer
System.Timers.Timer
System.Windows.Forms.Timer (Windows Form timer)
System.Windows.Threading.DispatcherTimer (WPF timer)
System.Threading.Timer是最简单的multi-thread timer
可以呼叫Change方法来改变执行的时间
以下范例是建立Timer, 等5秒後才开始做任务, 每个任务间隔1秒.
using System;
using System.Threading;
namespace ThreadingTImer
{
class Program
{
static void Main(string[] args)
{
Timer tmr = new Timer(Tick, "tick...", 5000, 1000);
Console.ReadLine();
tmr.Dispose();
}
static void Tick(object data)
{
Console.WriteLine(data);
}
}
}
实作Component, 可用在Visual Studio’s designer
不使用Change, 改成Interval property
不使用直接的委托, 而是Elapsedevent
用Enabled来启用或停止timer
如果对Enabled感到疑惑, 改用Start和Stop方法
AutoReset代表着重复执行的事件
SynchronizingObject property可以呼叫Invoke和BeginInvoke方法, 可以安全呼叫WPF / Winform的元件
using System;
using System.Timers;
namespace TimersTimer
{
class Program
{
static void Main(string[] args)
{
Timer tmr = new Timer();
tmr.Interval = 500;
tmr.Elapsed += tmr_Elapsed;
tmr.Start();
Console.ReadLine();
tmr.Stop();
Console.ReadLine();
tmr.Start();
Console.ReadLine();
tmr.Dispose();
}
private static void tmr_Elapsed(object sender, ElapsedEventArgs e)
{
Console.WriteLine("Tick");
}
}
}
Multi-thread timer是从thread pool的少量thread来支援timer, 也代表每次执行的委托任务, 都可能由不同的thread来执行.
Elapsed事件几乎是很准时的执行, 不管前一段时间的任务执行完毕与否, 因此委托给它的任务必须是thread-safe
Multi-thread timer的精准度是基於作业系统 误差约10~20ms, 如果要更精准, 需要使用native interop来呼叫Windows multimedia timer, 误差可降至1ms. 这interop定义在winmm.dll. 使用winmm.dll的一般流程:
呼叫timeBeginPeriod, 通知作业系统需要高精度的timing
呼叫timeSetEvent启用timer
任务完成後, 呼叫timeKillEvent停止timer
呼叫timeEndPeriod, 通知作业系统不再需要高精度的timing
Single-thread timer是用来在WPF或Winform, 如果拿到别的应用程序, 则那个timer将不会触发
Winform / WPF的timer并不是基於thread pool, 而是用User interface model的message pumping技术. 也就是Timer触发的任务都会是同一个thread, 而那thread是一开始建立timer的thread.
使用single-thread timer的好处:
忘记thread-safe的问题
Timer执行的任务(Tick), 必须前一个任务完成才会触发下一个
不需要呼叫元件的Invoke, 能直接在Tick委托任务执行更新UI元件的功能
WPF / Winform的timer只适合简单的任务, 否则需要采用multi-thread timer.
Single-thread的timer的精准度和multi-thread timer差不多, 会有几十ms的差异. 而会因为UI的request或其他timer的事件而造成更不准确.
<<: [读书笔记] Threading in C# - PART 2: BASIC SYNCHRONIZATION
>>: Day 9 - 目前(传统)的机器学习三步骤(4)-训练之测试
本系列文之後也会置於个人网站 在这之前,都是先请大家照着做,没有好好说明关於Keycloak的使用...
星期日,感觉是个适合算数的好日子,所以今天的文件,就决定是你了 Virtual Memory Lay...
kotlin+mvvm+databinding+recyclerview 上一篇讲了一般kotlin...
前言 上集我们介绍到 Enum 基础用法,今天将来讲解其它用法。 字串列举(String enum)...
WHO? WHAT? CSS到底是谁? CSS的全名为Cascading Style Sheets,...