C#基础--多线程

一.微软早期操作系统中的问题

在早期的操作系统中,应用程序都是在同一个地址空间中运行的,每个程序的数据其它程序都是可见的,并且因为早期CPU是单内核 的所以所有的执行都是线性的。这就引出两个问题:

第一:数据的安全性问题,如果有一个恶意程序被加载到内存当中,意味着它可以查看所有程序的数据,诸如密码,帐号之类,非常的不安全。

第二:如果有一个程序出现死循环,或者出现错误 ,意味着其它程序没有机会执行,而对于用户来说,只能关机或重启,用户体验非常之差。

对于两个问题微软重新设计了操作系统的内核,

  1. 进程的出现

每个加载到内存的程序都有自已的完整内存空间,这个内存空间是虚拟的,只对本进程可见,其它程序不可见,而这个内存空间部分通过某种规则映射到物理内存上。这就保证了用户数据的安全。这就相当于抽像出来了内存。

  2.线程的出现

    线程主要是为了抽像CPU资源,线程给程序的感觉是有一个CPU在一直执行我的代码,而真实的情况是,我们的机器没有那么多CPU,只是操作系统虚拟出来了CPU来执行代码,操作系统通过论询的方式,给每个线程一小段时间在CPU上执行,然后讯速切换到下一个线程,这样每个线程都有机会执行,线程切换相对于用户的感觉很短,所以对用户来说,程序就像一直在执行一样,这样一个程序出现死循环,不会影响到其它程序。

二.普通线程及问题,计算限制的异步操作

  在早期的机器上CPU都是单核的,所以线程的多少对执行效率不会出现特别明显的提升,而现在CPU大多已经多核了,所以很有必要运用多线程技术来提高执行效率,而且一些桌面程序的主线程都在忙活界面时,再创建一些线程出来来计算,或者写数据,从而不阻塞主线程,给用户的体验也是不错的。

创建一个线程可以用以下代码:

static void Main(string[] args)

{

List<string> state = new List<string>();

Thread t = new Thread(new ParameterizedThreadStart(OtherThread), 5000);

t.Start(state);

Console.ReadKey();

}

static void OtherThread(object sta)

{

Console.WriteLine("other thread");

}

这种方式创建的线程默认是前台线程,一个进程的结束是所有前台线程的结束才会关闭,可以用t.IsBackground = true;来设置这个线程变为一个后台线程。

在CLR中线程是对操作系统线程的一个包装,现在来讲,一个CLR线程和一个操作系统线程是一对一的关系,没准在以后的版本中会变。

创建线程是一个有点浪费资源的事情,因为创建一个线程要给他在内存中分配空间以容纳下面这些数据:

1.thread kernal object,线程内核对象,这个是存储在内核模式的内存中的,主要有一些线程的属性,还有线程的寄存器的值。

2.线程环境块,存储在用户模式内存中,包含线程的异常链表,线程每进入一个try,就会在链表中插入一项,每退出一个try会从链表中删除一项,另外环境块还包含线程的本地存储的一些信息。

3.用户模式栈,在用户模式内存中,包含线程执行中的本地变量和参数。函数调用的返回地址等。

4.内核模式栈,在内核模式的内存中,线程有时候会调用内核模式的函数,这时函数的参数会被复制到内存模式的栈中并验证参数的正确性,等函数执行完后再返回到用户模式栈中。

除了在内存方面的占用外,线程对CPU的无意义占用也有一些消耗,比如在线程切换的时候,

1.首选要把现在CPU执行线程的值放到线程内核对象中。

2.操作系统通过算法,找出要切换到的线程。

3.把要切换到的线程的CPU寄存器的值从目标线程内核对象中加载到寄存器中

整个切换过程CPU的所有运行,都没有对我们的生产效率起到作用,也就是没有做我们真正希望它做的动作。如果线程属于另一个进程,这种浪费会更严重,因为操作系统还要把内存映射空间切换到目标线程所在的进程地址空间。

所以基于以上的原因,并不是我们创建的线程越多越好,而是应该尽可能创建有效的线程,用最少的线程做更多的事,而不是拼命创建线程,让CPU把时间都浪费在上下文切换这种事儿上。

有一种机制比较好,就是我们创建一些线程,缓存起来,平常的时候让这些线程休眠,而不占用CPU时间让他切来切去,在有工作的时候让他运行,没有工作的时候就让他休息。

.net framework为我们提供了这样的支持 ,这就是线程池技术。

我们可以用:ThreadPool.QueueUserWorkItem(new WaitCallback(OtherThread), sta);来让线程池中的线程来执行我们的代码。

线程池中的线程默认都是后台线程。

还有一种运行线程池线程的方式就是,构造一个Task对象。如下:

Task<string> t = new Task<string>(OtherThread);

t.Start();

task会比QueueUserWorkItem占用的内存多一点,但task可以有返回值,或者后继任务,灵活性比QueueUserWorkItem高一点。

另外还可以用CancellationTokenSource让线程支持取消。

三.IO限制的异步操作

  主要是APM(异步编程模型)

四.应用开发中的异步应用,MVC ,async,await

五线程同步

  1. 用户模式的线程同步构造。

优点是比内核模式的同步构造快,因为内核模式构造还要切换到内核模式。

缺点就是操作系统不知道,所以一但阻塞了线程还是会被调度,浪费CPU时间。

所以使用建议是,在很快就完成的代码上用用户模式构造,在比较慢的时候用内核 模式构造。

分两种:

一种是易失构造:在简单数据类型的变量上实现原子性的读或写。

一种是互斥构造:在简单数据类型的变量上实现原子性的读和写。

易失构造用的是

Thread.VolatileRead(ref byte address)

Thread.VolatileWrite(ref byte address,byte value)

内建的关链字是volatile

读操作实现的是:不缓存数据,直接从RAW中读取,并且其它的变量在本变量读取之后读取。

写操作实现的是:不缓存数据,直接写入RAW中,并且其它的变量在本变量写之后全部已写入。

互斥构造用的是:

Interlocked.Exchange(ref double location1,double value)

同样可以保证写之前的变量已写入,读之后的变量还没读之外,还可以保证本次的读和写也是原子性的。

另外可以用它构造自悬锁。

  1. 内核模式的线程同步构造。

内核 模式的同步构造分为两种,一种是事件,一种是信号量

内核 模式的同步构造都是实现自WaitHandle,所以都具有这个类所有的方法。

事件同步构造其实是在内核中维护一个bool的变量,当变量是true时,线程不阻塞,当变量为false时,阻塞。

有两个类的实现,两个类都实现自EventWaitHandle类,这个类有两个方法,一个是Set,将内核 变量设为True不阻塞线程,另一个是Reset,使线程阻塞。

ManualResetEvent,手动重置事件,特点是:当用Set让线程不阻塞时,所有线程全部不阻塞,全部可执行,需要再手动设置成阻塞状态。

AutoResetEvent,手动重置事件,特点是,当用Set让线程不阻塞时,只有一个线程变成不阻塞,之后事件双变成false,以阻塞其它线程。

信号量

Semaphore,相当于一个计数器,在内核中维护的是一个int类型的变量,当计数器为0时阻塞线程,当大于0时不阻塞,每次WaitOne时,计数器自动减一,每次一个线程Realse时,计数器自动加1,在构造类型的时候,可以指定最小和最大的计数器。

  1. .net中的lock关键字以及Monitor,及存在的问题和建议。

Monitor相当于一个混合同步构造,内部即用了用户模式构造也用了内核模式构造,结合了两个的优点,用户模式的快,和内核模式的不点用CPU。

.net专门用一个关键字来构造Monitor同步构造:lock

bool isenter = false;

try

{

  Monitor.Enter(this, ref isenter);

  //code....

}

finally

{

  if (isenter)

  {

    Monitor.Exit(this);

  }

}

  而lock是不用传this的,那他是怎么实现的呢?

每个对象在堆的存储中都是一个同步块索引,这个索引指向同步块数组中的一个值,当本对象没有同步时这个索引为-1,当需要同步时,这个索引就指向数组中一项,这一项里面的存储结构大概是,占用线程ID,锁的引用次数,等。当在实例成员中用lock关键字时,lock的就是本对象的同步块索引指向的同步块,如果在类成员中用lock时,指向的就是类型对象的同步块索引指向的同步块。

建议用lock时要特别小心,因为他锁的永远是本对象的同步块,如果有线程嵌套的情况就会产生死锁。

建议改用Monitor,自已初始化一个私有变量传入做为锁定对象。