第4章 资源管理和序列化
建议46:显示释放资源需继承接口IDisposable
(1) 托管资源:由CLR管理分配和释放的资源,即从CLR中new出来的对象;
非托管资源:不受CLR管理的对象,如Windows内核对象,或者文件、数据库连接、套接字、COM对象等。
(2) 应该使用Dispose模式来继承IDisposable接口。
(3) 如果类型需要显示释放资源,那么一定要继承IDisposable接口。
(4) IDisposable接口可用于语法糖using:
using (SampleClass c1 = new SampleClass()) {}
using (SampleClass c1 = new SampleClass(), c2 = new SampleClass()) {}
using (SampleClass c1 = new SampleClass())
using (SampleAnotherClass c2 = new SampleAnotherClass()) {}
建议47:即使提供了显式释放方法,也应该在终结器中提供隐式清理
(1) 终结器的意义在于:调用者不主动调用Dispose方法时,
终结器会被垃圾回收器调用,它被用作资源释放的补救措施。
(2) 如果调用了Dispose方法进行了显式资源释放,那么终结器就没有必要再运行了,
GC.SuppressFinalize用来通知这一点。
建议48:Dispose方法应允许被多次调用
(1) 一个类型的Dispose方法应允许被多次调用而不抛异常。
(2) 对象被调用过Dispose方法,并不表示其被置为null,且被垃圾回收机制回收过内存而彻底不存在。
因此应该在调用过Dispose方法后,再调用正常方法时抛出ObjectDisposedException。
建议49:在Dispose模式中应提取一个受保护的虚方法
(1) 受保护的虚方法用来做实际的清理工作.
(2) 子类要在自己的释放方法中调用base.Dispose方法。
(3) 如果不提供受保护的虚方法,很有可能让子类忽略到父类的清理工作。
建议50:在Dispose模式中应区别对待托管资源和非托管资源
(1) 主动调用Dispose方法需要同时处理托管资源和非托管资源。
(2) 在垃圾回收器调用的终结器中,处理非托管资源(托管资源被垃圾回收器回收了)。
(3) 托管资源中的普通类型不需要手动清理,非普通类型(IDisposable子类)需要手动清理。
建议51:具有可释放字段的类型或拥有本机资源的类型应该是可释放的
(1) 包含非普通类型字段的类型需要实现Dispose模式。
(2) 拥有非托管资源的类型需要实现Dispose模式。
建议52:及时释放资源
(1) 垃圾回收的时机:
- 系统具有低的物理内存;
- 由托管堆上已分配的对象使用的内存超出了可接受的范围;
- 调用GC.Collect方法。(垃圾回收器会负责调用它)
(2) 垃圾回收机制中"代"的概念:一共分为3代,即0代、1代、2代。
第0代包含一些短期生存的对象(如"栈对象"),当第0代满了的时候会进行垃圾回收。
(3) 使用语法糖using关键字自动调用Dispose接口。
建议53:必要时应将不再使用的对象赋值为null
(1) 局部变量没有必要赋值为null;
(2) 要及时地将不再使用的静态变量赋值为null;
(3) 尽量少用静态变量。
建议54:为无用字段标注不可序列化
(1) 把对象变成流称为序列化,相反的过程称为反序列化;
(2) 需要序列化的场合:
- 把对象保存到本地,需要使用的时候恢复这个对象;
- 把对象传到网络中的其他终端,然后在此终端还原对象;
- 其他场合,例如把对象复制到粘贴板中,然后使用快捷键恢复这个对象。
(3) 将无用字段标注为不可序列化的好处:
- 节省空间;
- 反序列化后没有意义的字段(如Windows句柄);
- 业务原因不允许序列化的字段(如密码);
- 如果字段本身对应的类型未被设置为可序列化,就该将其标注为不可序列化,否则运行时会抛异常。
(4) 属性不能应用NonSerialized特性,其本质是方法;
(5) 使用field: NonSerialized标记事件不能被序列化。
建议55:利用定制特性减少可序列化的字段
(1) 特性可以声明式地为代码中的目标元素添加注解;
(2) 在System.Runtime.Serialization命名空间下,有下面4个特性:
- OnDeserializedAttribute,对象在反序列化后立即调用此方法;
- OnDeserializingAttribute,在反序列化对象时调用此方法;
- OnSerializedAttribute,在序列化对象后调用该方法;
- OnSerializingAttribute,在序列化对象前调用该方法。
建议56:使用继承ISerializable接口更灵活地控制序列化过程
(1) 若果特性Serializable及其配套的特性不能满足自定义序列化要求时,需要继承ISerializable。
(2) 如果格式化器在序列化一个对象时,发现该对象继承了ISerializable接口,
它就会忽略掉类型所有的序列化特性,转而调用类型的GetObjectData方法来构造一个Serialization对象,
方法内部负责向这个对象添加所有需要序列化的字段。
(3) ISerializable接口的特性在版本升级中,可以处理类型因为字段变化带来的问题。
建议57:实现ISerializable的子类型应负责父类的序列化
(1) 子类实现ISerializable,父类没有实现该接口:
子类序列化/反序列化时要把父类中继承的字段做同等处理;
(2) 子类实现ISerializable,父类也实现了该接口:
子类序列化操作不变,反序列化时将父类的GetObjectData声明为虚函数,
在子类中重写该虚函数并调用父类的该方法(base.GetObjectData)。
建议58:用抛出异常代替返回错误码
(1) 使用CLR异常机制使得代码更清晰、易于理解;
(2) 在catch代码块中做尽可能少的操作;
(3) 在构造函数、操作符重载及属性中无法使用错误码。
建议59:不要在不恰当的场合下引发异常、
(1) 正常的业务流程不应使用异常处理;
(2) 不要总是尝试去捕捉异常或引发异常,而应该允许异常向调用堆栈网上传播;
(3) 对在可控范围内的输入和输出不引发异常;、
(4) 引发异常的情况:
- 如果运行代码后会造成内存泄漏、资源不可用,或者应用程序状态不可恢复,则引发异常。
- 在捕获异常的时候,如果需要包装一些更有用的信息,则引发异常。
- 如果底层异常在高层操作的上下文中没有意义,则可以考虑捕获这些底层异常,并引发新的有意义的异常。
(5) 如果使用的接口报告异常的机制是错误代码,最好重新引发该接口提供的错误。
建议60:重新引发异常时使用Inner Exception
(1) 使用Inner Exception作为参数保留原始异常信息;
(2) 使用Exception的Data属性达到同样效果。
建议61:避免在finally内撰写无效代码
(1) 不存在打破try-finally执行顺序的情况;
(2) try块中内存异常时(例如非托管空指针操作)不会执行finally代码;
(3) 在CLR中,方法的参数及返回值都是用栈来保存的。
- 如果参数的类型是值类型,压栈的就是复制的值;
- 如果是引用类型,压栈的是对象的引用;
(4) 在finally块内修改try中的return变量时,
值类型的类型不会生效,引用类型的修改会生效(赋值为null除外)。
建议62:避免嵌套异常
(1) 过多的使用catch会使代码更多,并且会隐藏堆栈信息;
(2) 直接throw err而不是throw将会重置堆栈信息。
建议63:避免“吃掉”异常
(1) 如果不知道如何处理某个异常,那么千万不要“吃掉”异常;
(2) 不容易解决“吃掉”的异常。
建议64:为循环添加Tester-Doer模式而不是将try-catch置于循环内
(1) 不能在循环中引发异常;
(2) 在循环中进行条件判断以避免异常的发生。
建议65:总是处理未捕获的异常
(1) C#在AppDomain提供了UnhandledException事件接收未捕获的异常的通知;
(2) UnhandledException提供的保护机制并不能阻止应用程序终止;
(3) 使用UnhandledException事件来处理非UI线程异常;
(3) 在Winform中使用ThreadException事件来处理UI线程异常;
(4) 在WPF中使用DispatcherUnhandledException事件来处理UI线程异常。
建议66:正确捕获多线程中的异常
(1) 从.NET 2.0开始,任何线程上未处理的异常,都会导致应用程序退出(先触发AppDomain的UnhandledException);
(2) 原则上,每个线程的业务异常应该在自己的内部处理完毕;
(3) Winform中,可以使用窗体的BeginInvoke方法将异常传递给主窗体线程;
(4) WPF中,使用Dispatcher.Invoke方法将异常传递给主线程上。
建议67:慎用自定义异常
(1) 一般不要创建自定义异常;
(2) 自定义异常的理由:
- 方便调试
- 逻辑包装
- 方便调用者编码
- 引入新异常类
建议68:从System.Exception或其他常见的基本异常中派生异常
(1) 自定义异常类必须可序列化,因为异常类需要穿越AppDomain边界;
(2) 重写自定义异常类的Message属性。
建议69:应使用finally避免资源泄漏
(1) 除非发生让应用程序中断的异常,否则finally总是会先于return执行;
(2) 资源释放的最佳位置就是finally块中,随着调用堆栈由下往上执行;
(3) finally不会因为调用堆栈中存在异常而被终止,CLR会先执行catch块,然后再执行finally块。
建议70:避免在调用栈较低的位置记录异常
(1) 应该记录异常的情况:异常发生的场景需要记录,未被捕获的异常;
(2) 最适合进行异常记录和报告的时应用程序最上层,通常是UI层。
第6章 异步、多线程、任务和并行
建议71:区分异步和多线程应用场景
(1) DMA即直接内存访问,是一种不经过CPU而直接进行内存数据存储的数据交换模式,
通过DMA的数据交换几乎可以不消耗CPU资源(硬盘、网卡、声卡、显卡都有DMA功能);
(2) CLR提供的异步编程模型可以充分利用硬件的DMA功能来释放CPU压力;
(3) 异步模式使用线程池进行管理;
(4) 计算密集型工作,采用多线程;IO密集型工作,采用异步机制。
建议72:在线程同步中使用信号量
(1) 所谓线程同步,就是多个线程在某个对象上执行等待,直到该对象被解除锁定;
(2) CLR中值类型不能被锁定,引用类型上的等待机制分为锁定和信号同步;
(3) 锁定使用关键字lock和类型Monitor,前者是后者的语法糖;
(4) 信号同步机制涉及的类型都继承自抽象类WaitHandle(底层维护一个系统内核句柄):
EventWaitHandle(AutoResetEvent、ManualResetEvent)、Semaphore以及Mutex;
(5) EventWaitHandle维护一个由内核产生的布尔类型对象("阻滞状态"),其值为false时就阻塞,
可调用Set方法将其值设置为true,解除阻塞;
(6) Semaphore维护一个由内核产生的整型变量,其值为0时线程阻塞,否则就解除线程阻塞,
每解除一个线程阻塞,其值就减1;
(7) EventWaitHandle和Semaphore都只能在单应用程序域中进行线程同步,
Mutex具有跨应用程序域阻塞和解除阻塞的能力(可用于单进程管理);
(8) AutoResetEvent和ManualResetEvent的区别:
前者在发送信号完毕后(调用Set方法),会自动将阻滞状态设置为false,
后者需要进行手动设置。
建议73:避免锁定不恰当的同步对象
(1) 同步线程的另一种方式是使用线程锁;
(2) 选择同步对象的注意事项:
- 同步对象在需要同步的多线程中是可见的同一个对象
- 在非静态方法中,静态变量不应作为同步对象
- 值类型对象不能作为同步对象(线程锁定的是不同对象)
- 避免将字符串作为同步对象(不同同步线程之间可能会误同步)
- 降低同步对象的可见性
(3) 类型的静态方法应当保证线程安全,非静态方法不需要实现线程安全。
建议74:警惕线程的IsBackground
(1) 在CLR中,线程分为前台线程和后台线程,即每个线程都有一个IsBackground属性;
(2) 前台线程和后台线程的区别:所有前台线程都退出,应用程序才会退出;
应用程序退出时,后台线程会一并退出。
(3) 线程池中的线程都是后台线程;
(4) 只有线程正在执行事务或占有需要释放的非托管资源时,才使用前台线程。
建议75:警惕线程不会立即启动
(1) 线程之间的调度占有一定的时间和空间开销,而且不实时。
建议76:警惕线程的优先级
(1) 线程在C#中有5个优先级:Highest、AboveNormal、Normal、BelowNormal、Lowest;
(2) Windows系统是一个基于优先级的抢占式调度系统;
(3) 高优先级的线程总是在系统调度算法中获取更多的CPU执行时间;
(4) 在C#中使用Thread和ThreadPool新起的线程,默认优先级都是Normal,
(5) 一般不建议修改线程的优先级;
(6) 具有运行时间短、能即刻进入等待状态等特征的线程可以提高优先级。
建议77:正确停止线程
(1) FCL提供的标准取消模式:协作式取消(Cooperative Cancellation);
(2) 协作式取消的关键类型是CancellationTokenSource:
它的CancellationToken类型属性Token,提供了IsCancellationRequested属性作为取消工作的标识;
CancellationToken还提供了Register方法,来注册一个Action委托,在线程停止时被回调。、
建议78:应避免线程数量过多
(1) 线程数量过多的影响:
- 占用过多内存
- 在过多的线程上下文中切换回损耗很多CPU时间
- 新起的线程可能需要等待相当长的时间才会真正运行
(2) 需要使用线程时考虑使用线程池技术;
(3) IO密集型工作使用异步机制。
建议79:使用ThreadPool或BackgroundWorker代替Thread
(1) 线程的空间开销:
- 线程内核对象(Thread Kernel Object),主要包含线程上下文对象,32位系统占700字节左右内存
- 线程环境块(Thread Environment Block),包括异常处理链,32位系统占4KB内存
- 用户模式占(User Mode Stack),即线程栈,保存方法的参数、局部变量和返回值,占用1024KB内存
- 内核模式栈(Kernel Mode Stack),32位系统占12KB内存
(2) 线程的时间开销
- 线程创建时,初始化空间开销
- 接着CLR调用所有加载DLL的DLLMain方法,并传递连接标志(线程终止时进行类似操作,并传递分离标志)
- 线程上下文切换,每个线程大概得到几十毫秒的执行时间片,然后就会切换到下一个进程了
(3) 由线程到线程池:
var t = new Thread(() => {}); t.Start();
ThreadPool.QueueUserWorkItem((objState) => {}, null);
(4) BackgroundWorker在内部使用了线程池技术,并提供和UI线程交互的能力;
(5) BackgroundWorker通过事件提供:报告进度、支持完成回调、取消任务、暂停任务等。
建议80:使用Task代替ThreadPool
(1) ThreadPool的缺点:
- 不支持线程的取消、完成、失败通知等交互性操作
- 不支持线程执行的先后次序
(2) 使用Task:
var t = new Task(() => {});
t.Start();
t.ContinueWith((task) => {});
(3) Task的属性:
- IsCanceled 因为被取消而完成
- IsCompleted 成功完成
- IsFaulted 因为发生异常而完成
(4) Task支持文件工厂的概念,可以同时取消一组任务;
(5) Task进一步优化类后台线程池的调度,加快了线程的处理速度。
建议81:使用Parallel简化同步状态下Task的使用
(1) 静态类Parallel简化了在同步状态下的Task操作;
(2) Parallel主要提供3个有用的方法:For、ForEach、Invoke:
- For方法主要用于处理针对数组元素的并行操作;
- ForEach方法主要用于处理泛型集合元素的并行操作;
- Invoke方法简化了启动一组并行操作,隐式启动Task,接受Params Action[]参数;
(3) 如果输出必须是同步的或者必须顺序输出,则不应该使用Parallel的方式;
(4) 使用For和ForEach方法,Parallel类型会自动分配Task来完成针对元素的工作。
建议82:Parallel简化但不等同于Task默认行为
(1) 在运行Parallel中的For、ForEach方法时,调用者线程是被阻塞的;
(2) Parallel虽然将任务交给CLR线程池去处理,但是调用者会一直等到线程池中的相关工作全部完成;
(3) 并行编程意味着运行时在后台将任务分配到尽可能多的CPU上,
虽然它在后台使用Task进行管理,但这并不意味着它等同于异步。
建议83:小心Parallel中的陷阱
(1) Parallel的For和ForEach方法还支持一些相对复杂的应用。在这些应用中,
它允许在每个任务启动时执行一些初始化操作,在每个任务结束后,又执行一些后续工作,
同时,还允许我们监视任务的状态;
(2) 但是,"允许我们监视任务的状态"是错误的:应该把"任务"改成"线程";这就是陷阱所在;
(3) public static ParallelLoopResult For
Func
Action
前两个参数分别是起始索引和结束索引,参数body即任务本身,
localInit的作用是如果Parallel为我们新起了一个线程,它就会执行一些初始化的任务,
localFinally的作用是,每个线程结束的时候,执行一些收尾工作。
建议84:使用PLINQ
(1) LINQ最基本的功能就是对集合进行遍历查询,并在此基础上对元素进行操作;
(2) 微软专门为LINQ扩展了一个类ParallelEnumerable,它所提供的扩展方法会让LINQ支持并行计算,即PLINQ;
(3) 传统的LINQ计算是单线程的,PLINQ则是并发的、多线程的;
(4) LINQ中泛型集合进行AsParallel操作后,可进行ForAll操作,不过它会忽略掉查询的AsOrdered请求;
(5) 建议在对集合中的元素项进行操作的时候使用PLINQ代替LINQ(但要辨别顺序查询等意外情况)。
建议85:Task中的异常处理
(1) 在任务并行库中,如果对任务运行Wait、WaitAny、WaitAll等方法,或者求Result属性,
都能捕获到AggregateException异常,可将其看作任务并行库编程中最上层的异常;
(2) 在任务中捕获的异常,最终都应该包装到AggregateException中;
(3) 运行Wait、WaitAny、WaitAll等方法,或者求Result属性会阻滞当前线程,
新起一个后续的任务,就可以解决等待的问题(在线程池中处理异常);
(4) 使用事件通知的方式将异常包装到主线程;
(5) 任务调度器TaskScheduler有一个静态事件用于处理未捕获到的异常,
不过事件回调是在进行垃圾回收的时候才发生的,因此不建议这样使用。
建议86:Parallel中的异常处理
(1) Parallel的调用者线程会等到所有任务全部完成后,再继续自己的工作;
(2) 在Parallel方法体中抛出AggregateException异常。
建议87:区分WPF和WinForm的线程模型
(1) WPF和WinForm窗体应用程序的UI元素都必须由创建它的线程更新;
(2) WinForm所有的UI元素继承ISynchronizeInvoke接口,其InvokeRequired属性
表示了当前线程是否是创建它的线程,Invoke/BeginInvoke方法负责将消息发送到
消息队列中;
(3) WPF控件继承自Visual,Visual又继承自DispatcherObject类,其属性Dispatcher
完成所有的工作线程和UI线程之间的调度任务,CheckAccess方法负责检测工作线程是否
可以访问控件,VerifyAccess方法负责检测工作线程是否具有控件的访问权;
(4) WinForm使用类似CheckAccess的方法判断访问权限,WPF使用VerifyAccess的方法
判断访问权限,因此WinForm可能会成功执行,WPF只要没有UI访问权限就会抛出异常,
这也导致了WinForm线程模型的不稳定;
建议88:并行并不总是速度更快
(1) 并行所带来的的后台任务及任务的管理,都会带来一定的开销;
(2) 并行任务所需的时间越长,或者循环体越大,并行运行速度越快。
建议89:在并行方法体中谨慎使用锁
(1) 本身就需要同步运行,或者需要较长时间锁定共享资源时,不应该使用并行;
(2) FCL对简单类型的原子操作提供了Interlocked静态类,volatile关键字,泳衣提高性能;
(3) 如果方法体的全部内容都需要进行同步,就完全不应该使用并行。