【C# 线程】线程 开端


概述

线程主要学习什么,通过一个月的摸索、终于总结出来了:
学习涉及到
学习Thread类涉及到
线程单元状态: ApartmentState、GetApartmentState\SetApartmentState
内存屏障:VolatileWrite()、VolatileRead()、MemoryBarrier
线程共享变量原子操作:interLoacked() 具有原子性、可见性、有序性。
线程本地变量的存储:LocalThread、ThreadStatic、Lazy、数据曹(AllocateDataSlot()、AllocateNamedDataSlot()、GetData()、SetData()、LocalDataStoreSlot类)
可变内存操作:Volatile 类 \VolatileRead()\VolatileWrite() 具有可见性和原子性、CAS机制
线程同步问题:同步锁(spinlock、interlocked原子锁、)、临界区、临界资源(BeginCriticalRegion() 。 EndCriticalRegion())

线程自旋 :spinwait
线程时间片转让: sleep(0)、sleep(1)、yeild ()
线程状态:ThreadState枚举
线程类型:前后台线程
线程状态的操作:jion()\sleep()\Interrupt()
线程优先级:程序员可控制的5个Priority ThreadPriority枚举

线程亲和力:BeginThreadAffinity ()和 EndThreadAffinity()
应用域:GetDomain()、GetDomainID()
进程:GetCurrentProcessorId()
线程上下文:ExecutionContext()、SynchronizationContext类
线程安全:原子性、有序性、可见性
线程区域文化:CurrentUICulture\CurrentCulture
线程池:IsThreadPoolThread



背景

我们首先回顾进程的两个基本属性:

  • 进程使一个可拥有资源的独立单位
  • 进程同时又是一个可以独立调度和分派的基本单位。

正是由于这两个基本属性,才使进程成为一个能独立运行的基本单位,从而构成了进程并发执行的基础。

由于进程是一个资源的拥有者,因而在进程的创建、撤销、和切换的过程中,系统必须为之付出较大的时空开销。为了解决这个问题,不少操作系统的学者们想到:将进程的两个属性分开,由操作系统分开处理。即对作为调度和分派的基本单位,不同时作为独立分配资源的单位,以使之轻装运行;而对拥有资源的基本单位,又不频繁地对之进行切换,在这种思想的指导下,产生了线程的概念。

线程引入的原因: 为了减少程序并发执行所付出的时空开销,使os具有更好的并发性。

线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。

CLR线程和window线程

CLR线程完全等价于windows线程。System.Environment类公开了CurrentMangagedThreadID属性,返回的是线程的CLR ID。

而System.Diagnostics.ProcessThread类公开了Id属性,返回同一个线程的Windows ID。

 备注:如果想P/Invoke本地代码,而且代码必须使用当前物理操作系统的线程来执行,那么应该调用System.Threading.Thread的静态BeginThreadAffinity方法。BeginThreadAffinity就是告诉CLR不要切换线程。线程不再需要使用物理操作系统线程运行时,可调用Thread的EndThreadAffinity方法来通知CLR。

主线程和子线程

当一个程序启动时,就有一个进程被操作系统(OS)创建,与此同时一个线程也立刻运行,该线程通常叫做程序的主线程(Main Thread),因为它是程序开始时就执行的,如果你需要再创建线程,那么创建的线程就是这个主线程的子线程,它是前台线程。

前台线程与后台线程

前台线程默认是MTA

1、在CLR中,线程要么是前台线程,要么就是后台线程。当一个进程的所有前台线程停止运行时,CLR将强制终止仍在运行的任何后台线程,不会抛出异常。

2、在C#中可通过Thread类中的IsBackground属性来指定是否为后台线程。在线程生命周期中,任何时候都可从前台线程变为后台线程。线程池中的线程默认为后台线程

3、在C#中,Thread类默认创建的是前台线程,通过线程池(后面会讲到)创建的线程都是后台线程。

4、前台线程与后台线程唯一的区别是后台线程不会阻止进程终止。

5、尽量避免使用前台线程。

线程优先级

Windows支持6个进程优先级类(priority class) :Idle,Below NormalAbove NormalHight(非必要不用)Realtime(最好不要用)。默认的Normal是最常用的优先级类。

Windows支持7个相对线程优先级IdleLowestBelow NormalNormalAbove NormalHighestTime-Critical

优先级类和优先级合并构成一个线程的“基础优先级”(base priority)。注意,每个线程都有一个动态优先级(dynamic priority)。线程调度器根据这个优先级来决定要执行哪个线程。最初线程的动态优先级和它的基础优先级是相同的。

线程优先级:windows 操作系统的线程优先级分为 0~31级。应用程序开发人员永远不直接处理这些优先级。只要使用相对优先级就够了。

系统将进程的优先级类和其中的一个线程的相对优先级映射成一个优先级(0~31)。下表总结了进程的优先级类和线程的相对优先级与优先级(0~31)的映射关系。

进程优先级类”和"相对线程优先级"如何映射到“优先级”值


进程优先级类




相对线程优先级
Idle Below Normal Normal Above Normal High Realtime
Time-Critical 15 15 15 15 15 31
Highest 6 8 10 12 15 26
Above Normal 5 7 9 11 14 25
Normal 4 6 8 10 13 24
Below Normal 3 5 7 9 12 23
Lowest 2 4 6 8 11 22
Idle 1 1 1 1 1 16

例如,Normal进程中的一个Normal线程的优先级是8。由于大多数进程都是Normal优先级,大多数线程也是Normal优先级,所以系统中大多数线程的优先级都是8。

注意,表中没有值为0的线程优先级。这是因为0优先级保留给零页线程了,系统启动时会创建一个特殊的零页线程(zero page thread)。该线程的优先级是0,而且是整个系统唯一优先级为0的线程。在没有其他线程需要“干活儿”的时候,零页线程将系统RAM的所有空闲页清零。系统不允许其他线程的优先级为0。而且,以下优先级也不可获得:17,18,19,20,21,27,28,29或者30。以内核模式运行的设备驱动程序才能获得这些优先级;用户模式的应用程序不能。还要注意,Realtime优先级类中的线程优先级不能低于16。类似地,非Realtime的优先级类中的线程优先级不能高于15。

而在C#程序中,可更改线程的相对优先级,需要设置ThreadPriority属性,可设置为ThreadPriority枚举类型的五个值之一:Lowest、BelowNormal、Normal、AboveNormal 或 Highest。CLR为自己保留了IdleTime-Critical优先级,程序中不可设置。

windows使用32个线程优先级,分成三类:

线程生命周期的各个状态

通过ThreadState可以检测线程是处于Unstarted、Sleeping、Running 等等状态,它比 IsAlive 属性能提供更多的特定信息。

ThreadState main=  Thread.CurrentThread.ThreadState

public static ThreadState SimpleThreadState (ThreadState ts)
{
  return ts & (ThreadState.Unstarted |
               ThreadState.WaitSleepJoin |
               ThreadState.Stopped);
}

 System.Threading.Thread.ThreadState属性定义了执行时线程的状态。

Unstarted状态:Thread thread=new ()

Running状态:

(1)另一个线程调用 Resume
(2)另一个线程调用 Interrupt    
(3)一个线程执行新的一个线程的thread.Start();方法直到新线程开始运行,方法才会返回。

WaitSleepJoin状态::

(1)线程调用 Sleep     
(2)线程对另一个对象调用 Monitor.Wait。     
(3)线程对另一个线程调用 Join。

SuspendRequested状态另一个线程调用该线程的 Suspend()

Suspended状态线程响应 Suspend 请求。

AbortRequested状态另一个线程调用 Abort

Aborted状态:线程状态包括 AbortRequested 并且该线程现在已死,但其状态尚未更改为 Stopped。

SuspendRequested状态:另一个线程请求挂起

Suspended状态:响应挂起

StopRequested状态:正在请求线程停止。 这仅用于内部。

Stopped状态

(1)线程响应 Abort 请求。
(2)线程终止。

Background状态它指示线程是在后台运行还是在前台运行

简单概述以下:

1、线程从创建到线程终止,它一定处于其中某一个状态。当线程被创建时,它处在Unstarted状态.

2、Thread类的Start() 方法将使线程状态变为Running状态,线程将一直处于这样的状态,除非我们调用了相应的方法使其挂起、阻塞、销毁或者自然终止。

3、如果线程被挂起,它将处于Suspended状态,除非我们调用resume()方法使其重新执行,这时候线程将重新变为Running状态。

4、一旦线程被销毁或者终止,线程处于Stopped状态。处于这个状态的线程将不复存在,正如线程开始启动,线程将不可能回到Unstarted状态。

5、线程还有一个Background状态,它表明线程运行在前台还是后台。

6、在一个确定的时间,线程可能处于多个状态。据例子来说,一个线程被调用了Sleep而处于阻塞,而接着另外一个线程调用Abort方法于这个阻塞的线程,这时候线程将同时处于WaitSleepJoin和AbortRequested状态。一旦线程响应转为阻塞或者中止,当销毁时会抛出ThreadAbortException异常。

以上状态对应Thread线程的操作方法:

Join()方法:阻塞直到某个线程终止时为止。
Sleep()方法:将当前线程阻塞指定的毫秒数,Sleep()使得线程立即停止执行,线程将不再得到CPU时间。
Start()方法:开始运行,调用该方法,知道线程开始运行,才返回
Interrupt()方法:中止处于 WaitSleepJoin 状态的线程,让线程重新running。
Suspend()方法:

过时  因为容易造成死锁,调用Suspend()方法 停止线程运行,不是及时的,它要求公共语言运行时必须到达一个安全点,线程将不再得到CPU时间。
但是可以调用Suspend()方法使得另外一个线程暂停执行。对已经挂起的线程调用Thread.Resume()方法会使其继续执行。不管使用多少次Suspend()方法来阻塞一个线程,只需一次调用Resume()方法就可以使得线程继续执行。
尽可能的不要用Suspend()方法来挂起阻塞线程,因为这样很容易造成死锁。假设你挂起了一个线程,而这个线程的资源是其他线程所需要的,会发生什么后果。
因此,我们尽可能的给重要性不同的线程以不同的优先级,用Thread.Priority()方法来代替使用Thread.Suspend()方法。
Resume()方法:过时 恢复挂起 配合suspend一起使用的
Abort()方法:

过时。.NET 5(包括 .NET Core)及更高版本不支持 Thread.Abort 方法,CancellationToken 已成为一个安全且被广泛接受的 Thread.Abort 替代者。如果线程已经在终止 。Thread.Abort()方法使得系统悄悄的销毁了线程而且不通知用户。一旦实施Thread.Abort()操作,该线程不能被重新启动。调用了这个方法并不是意味着线程立即销毁,因此为了确定线程是否被销毁,我们可以调用Thread.Join()来确定其销毁。对于A和B两个线程,A线程可以正确的使用Thread.Abort()方法作用于B线程,但是B线程却不能调用Thread.ResetAbort()来取消Thread.Abort()操作。
ResetAbort()方法:

只有具有适当权限的代码才能调用此方法。

当调用 Abort 终止线程时,系统将引发 ThreadAbortException 。 ThreadAbortException 是一个特殊的异常,可由应用程序代码捕获,但会在 catch 块结束时重新引发,除非 ResetAbort 调用。 ResetAbort 取消要中止的请求,并阻止 ThreadAbortException 终止线程。

ThreadAbortException有关演示如何调用方法的示例,请参阅 ResetAbort

线程上下文

前面说过,一个应用程序域中可能包括多个上下文,而通过CurrentContext可以获取线程当前的上下文。

CurrentThread是最常用的一个属性,它是用于获取当前运行的线程。

Cpu执行线程过程

cpu加载当前线程上下文》执行计算》保存当前线程上下文》运行另外一个线程

线程的标识符

ManagedThreadId是确认线程的唯一标识符,程序在大部分情况下都是通过Thread.ManagedThreadId来辨别线程的。而Name是一个可变值,在默认时候,Name为一个空值 Null,开发人员可以通过程序设置线程的名称,但这只是一个辅助功能。

单独线程的使用范围

1.如果线程要一直运行(如Word的拼写检查器线程),就应使用Thread类创建一个线程。入池的线程只能用于时间较短的任务。

线程安全是什么?

什么是线程安全?线程安全是怎么完成的(原理)?
线程安全就是说多线程访问同一代码,不会产生不确定的结果。编写线程安全的代码是低依靠线程同步。

在并发编程中,线程安全问题的本质其实就是 原子性、有序性、可见性;接下来主要围绕这三个问题进行展开分析其本质,彻底了解可见性的特性。

  • 原子性 和数据库事务中的原子性一样,满足原子性特性的操作是不可中断的,要么全部执行成功要么全部执行失败。C#中解决原子性用C#中解决原子性用 volatile关键字、volatile类。1、Interlocked.Increment(ref a)取代a++、Interlocked.Decrement(ref a)取代i--、给代码加锁

  • 有序性(同步性) 编译器和处理器为了优化程序性能而对指令序列进行重排序,也就是你编写的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性问题。C#中解决有序性  内存屏障、 加锁。

  • 可见性 多个线程访问同一个共享变量时,其中一个线程对这个共享变量值的修改,其他线程能够立刻获得修改以后的值。C#中解决可见性用 volatile类。

为了彻底了解这三个特性,我们从两个层面来分析,第一个层面是硬件层面、第二个层面是JMM层面

详细请查看:

线程局部存储

在多线程编程要用到线程局部变量的存储。相当于线程级别的static

为了确保在线程中声明特定类型的变量,在每个线程中的值都是唯一的,不受到其他线程对该变量读写的影响。 也就是俗称的线程本地存储 (TLS),可用于存储对线程和应用程序域唯一的数据。

例如:主线程中声明了变量A ,只能由主线程进行读取和写入。子线程虽然可以使用变量A(相当于复制一个A,可以对该变量进行读写),却无法读取和写入主线变量中A的值。

.net提供了3种方式:

1、线程相对静态字段 相对于线程的静态字段(ThreadStatic

2、数据槽  LocalDataStoreSlot

3、ThreadLocal

C# 内存模型

在多线程编程要用到内存模型

1、内存操作重新排序
2、
3、原子性:在 C# 中,值不一定以原子方式写入内存。支持原子性数据类型有: reference,bool,char,byte,sbyte,short,ushort,unit,int,float。不知此类型有long\double 因为他们是64位的,所有要看芯片类型,如果64位芯片那么久就支持原子性,32位不支持。
4、不可重新排序优化

线程同步

同步只要分为进程内线程同步,和进程之间同步。在进程内线程同步,轻量级锁(用户态)和重量级锁(内核锁)都可以使用。进程之间同步必须要用重量级锁(内核锁)。

轻量级锁:SpinLock, SpinWait, CountdownEvent, SemaphoreSlim, ManualResetEventSlim, Barrier

重量级锁:lock、mutex、Semaphores、event、Monitor

同步机制的四个准则:

  1. 空闲让进:屁话
  2. 忙则等待:屁话
  3. 有限等待:要防止饥饿现象
  4. 让权等待:进程不能进入临界区的时候要释放CPU

.NET 中线程同步的方式多的让人看了眼花缭乱,究竟该怎么理解?其实,抛开.NET 环境看线程同步,无非执行两种操作:
     1. 互斥/加锁,目的是保证临界区代码操作的"原子性";
     2. 信号灯操作,目的是保证多个线程按照一定顺序执行,如生产者线程要先于消费者线程执行。
.NET 中线程同步的类无非是对这两种方式的封装,目的归根结底都可以归结为实现互斥/加锁或者是信号灯这两种方式,只是它们的适用场合有所不同。

实现线程间同步4种常用方法:

1)临界区(Critical Section)

指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源(如打印机)又无法同时被多个 线程 访问的特性 。临界区只限制与同一进程的各个线程之间使用,C#中使用以下锁实现同进程里不同线程的同步。

  •  lock 同步锁: 使用比较简单 lock(obj){ Synchronize part  };  只能传递对象,无法设置等待超时;通常,应避免锁定 public 类型,否则实例将超出代码的控制范围。常见的结构 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 违反此准则
    最佳做法是定义 private 对象来锁定, 或 private static 对象变量来保护所有实例所共有的数据。

entry section 进去区 该区检查是否可以进去临界区
critical section  访问临界资源那段代码
exit section 退出区     将访问临界区的标志清除
remainder  section 剩余区。代码中的其余部分

  • InterLocked原子同步锁 原子操作,提供了以线程安全的方式递增,递减,交换和读取值的方法;
  •  Monitor同步锁: lock语句等同于Monitor.Enter() ,同样只能传递对象,无法设置等待超时,

2)互斥量(Mutex)

为协调共同对一个共享资源的单独访问而设计的。

3)信号量(Semaphores)

为控制一个具有有限数量用户资源而设计。

4)通知事件(Event)

通知事件对象可以通过通知操作的方式来保持线程的同步。c#中的AutoResetEvent 类 ManualResetEvent 类属于通知事件。

线程内部同步

1)自旋锁 (SpinLock)

2)内存屏障

线程的开销:

线程的空间开销主要来自:
1)线程内核对象(Thread Kernel Object)。每个线程都会创建一个这样的对象,它主要包含线程上下文信息,占用的内存在700字节左右。
2)线程环境
块(Thread Environment Block)。占用4KB内存。
3)用户模式栈(User Mode Stack),即线程栈。线程栈用于保存方法的参数、局部变量和返回值。每个线程栈占用1MB的内存。要用完这些内存很简单,写一个不能结束的递归方法
,让方法参数和返回值不停地消耗内存,很快就会发生OutOfMemoryException。
4)内核模式栈(Kernel Mode Stack)。当调用操作系统的内核模式函数
时,系统会将函数参数从用户模式栈复制到内核模式栈。会占用12KB内存。


线程的时间开销来自:
1)线程创建的时候,系统相继初始化以上这些内存空间。
2)接着CLR会调用所有加载DLL的DLLMain方法,并传递连接标志(线程终止的时候,也会调用DLL的DLLMain方法,并传递分离标志)。
3)线程上下文切换。一个系统中会加载很多的进程,而一个进程又包含若干个线程。但是一个CPU在任何时候都只能有一个线程在执行。为了让每个线程看上去都在运行,系统会不断地切换“线程上下文”:每个线程大概得到几十毫秒的执行时间片,然后就会切换到下一个线程了。

这个过程大概又分为以下5个步骤:
步骤1 进入内核模式。
步骤2 将上下文信息(主要是一些CPU 寄存器信息
)保存到正在执行的线程内核对象上。
步骤3 系统获取一个 Spinlock,并确定下一个要执行的线程,然后释放 Spinlock。如果下一个线程不在同一个进程内,则需要进行虚拟地址交换。
步骤4 从将被执行的线程内核对象上载入上下文信息。
步骤5 离开内核模式。
所以线程的创建和销毁是需要付出时间和空间的代价的,而微软为了防止我们开发者无节制的使用线程,就封装了线程池这种技术,简单说就是帮助我们开发者来管理线程,随着工作的完成,线程不会被销毁,而是回到线程池中,看别的工作会不会继续使用线程,而具体何时被销毁或者创建,由CLR自己的算法来决定,所以真实项目中,我们更多的应该考虑使用线程池来替代Thread,C#中线程池主要有ThreadPool和BackgroundWorker这两个类,使用也蛮简单的:

ThreadPool.QueueUserWorkItem(state =>
{
    //todo
});
var bw = new BackgroundWorker();
bw.DoWork += (sender, e) =>
{
    //todo
};

bw.RunWorkerAsync();


而ThreadPool和BackgroundWorker的区别在于:BackgroundWorker在WinForm和WPF中还提供了和UI线程交互的能力,而ThreadPool没有这种能力,BackgroundWorker的能力还包括:通知进度、完成回调、取消任务、暂停任务等功能。

Task是.NET4.5之后提供的线程的更高级的一种技术,虽然前面刚说了ThreadPool和BackgroundWorker比Thread更有优势,那么Task更是超越ThreadPool和BackgroundWorker更强大的概念。为线程池提供了更多的API可以调用,管理一个线程简直颠覆传统了

线程池

1、每一个进程都有一个CLR线程池,当该进程销毁的时候,该线程池就会注销。

2、 对于COM对象,入池的所有线程都是多线程单元(Multi-threaded apartment,MTA)线程。许多COM对象都需要单线程单元(Single -threaded apartment,STA)线程。

3、单个任务处理的时间比较短

4、需要处理的任务的数量大

5、不能给入池的线程设置优先级或名称。

详细请看 线程池章节

什么是临界资源和临界区

1.临界资源
  临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。属于临界资源的硬件有,打印机,磁带机等;软件有消息队列,变量,数组,缓冲区等。诸进程间采取互斥方式,实现对这种资源的共享。

2.临界区:
  每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。不论是硬件临界资源还是软件临界资源,多个进程必须互斥的对它进行访问。多个进程涉及到同一个临界资源的的临界区称为相关临界区。使用临界区时,一般不允许其运行时间过长,只要运行在临界区的线程还没有离开,其他所有进入此临界区的线程都会被挂起而进入等待状态,并在一定程度上影响程序的运行性能。