C# 线程入门 00

内容预告:

  • 线程入门(线程概念,创建线程)
  • 同步基础(同步本质,线程安全,线程中断,线程状态,同步上下文)
  • 使用线程(后台任务,线程池,读写锁,异步代理,定时器,本地存储)
  • 高级话题(非阻塞线程,扶起和恢复)

概览:

C#支持通过多线程并行地执行代码,一个线程是独立的执行个体,可以和其他线程同时运行。

CLR和操作系统会给C#程序开启一个线程(主线程),可以被用来作为创建多线程的起点,例子:

复制代码
class ThreadTest {
static void Main() {
Thread t = new Thread (WriteY);
t.Start(); // Run WriteY on the new thread
while (true) Console.Write ("x"); // Write 'x' forever
}
static void WriteY() {
while (true) Console.Write ("y"); // Write 'y' forever
}
}
复制代码

运行结果将是:

复制代码
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
复制代码

主线程创建了一个线程 t ,执行了重复输出y的操作,主要线程执行了重复输出x的操作。
CLR给每个线程分配了单独的线程栈,所以本地变量是每个线程单独保存的,下面的例子,我们用一个本地变量定义一个函数,然后在main函数和新的线程里同时执行这个函数

复制代码
static void Main() {
new Thread (Go).Start(); // Call Go() on a new thread
Go(); // Call Go() on the main thread
}
static void Go() {
// Declare and use a local variable - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
复制代码

执行结果:

??????????

每个线程的内存栈里都创建了一个单独的变量cycle,所以输出是10个?
如果是引用同一个对象的话,线程则共享这个数据:

按 Ctrl+C 复制代码
按 Ctrl+C 复制代码

因为两个线程都调用Go(),它们共享done这个变量,所以done只输出一次:

Done

static变量提供一种不同的方式在线程中共享变量,这里是一个例子:

复制代码
class ThreadTest {
static bool done; // Static fields are shared between all threads
static void Main() {
new Thread (Go).Start();
Go();
}
static void Go() {
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
复制代码

这里输出就不太确定了,看起来要输出两次done,其实不可能。我们交换一下Go的次序,

复制代码
class ThreadTest {
static bool done; // Static fields are shared between all threads
static void Main() {
new Thread (Go).Start();
Go();
}
static void Go() {
if (!done) { Console.WriteLine ("Done"); done = true; }
}
}
复制代码

done就有可能输出两次。因为在一个线程计算if表达式然后执行Console.WriteLine ("Done");的时候,另一个线程可能有机会在done的值改变之前先输出done。
其实在C#中可以用lock来达到这个目的:

复制代码
class ThreadSafe {
static bool done;
static object locker = new object();
static void Main() {
new Thread (Go).Start();
Go();
}
static void Go() {
lock (locker) {
if (!done) { Console.WriteLine ("Done"); done = true; }
}
}
}
复制代码

当两个线程同时竞争一个锁时,一个线程等待,或者说阻塞,直到锁空出来。这主要是保证同时只能有一个线程可以进入临界代码区域,"Done"只会被输出一次。
代码是以这样的方式被保护的,来自于多线程上下文的不确定性,叫做线程安全。
临时地暂停,或阻塞,是线程同步的基本功能。
如果一个线程想要暂停,或者休眠一段时间,可以用:

Thread.Sleep (TimeSpan.FromSeconds (30)); // 阻塞30秒

一个线程可以通过调用Join等待另一个线程结束:

Thread t = new Thread (Go); // 假设Go是静态函数。
t.Start();
Thread.Join (t); // 阻塞,只到线程t结束。

线程如何工作:
在内部,多线程是被线程调度器管理的,是CLR代替操作系统干的活。线程调度器要保证所有活跃线程合理分配执行时间,以及在等待中的线程(这些线程是不消耗CPU时间的)。
在单核机器上,线程调度是以在活跃线程间快速切换时间片的方式工作的。就像就第一个例子,重复输出x或y的线程轮换得到时间片。在Windows XP下,一个时间片就是几十毫秒,这还是要比CPU在线程间切换能干更多事,一次线程切换也就几毫秒的事。
在多核机器上,多线程的实现是结合了时间片轮换和并发,并发是不同的线程同时运行在不同的CPU上,因为机器要运行的线程数远远大于CPU的数量,所以还需要时间片切换。
线程不能控制自己什么时候执行,完全由操作系统的时间片切换机制来控制。

线程和进程:

总是有面试官喜欢把线程和进程做比较,其实两者根本不是一个级别的东西。一个单独的应用程序内所有的线程都在逻辑或属于一个进程的。进程:一个运行应用程序的操作系统单元。线程与进程有些相似之处,比如:对于实例,进程和线程都是典型的时间片轮换的执行机制。关键的不同点在于进程间是相互独立的,而同一个应用程序里的线程间是共享堆内存的,这也是性能的用武之地:一个线程可以在后台运行,另一个线程可以显示得到的数据。

什么时候应该用多线程:

  • 一个普通的多线程程序在后台运行耗时的任务时。主线程保持运行状态,工作线程干后台的活。在Windows Form程序里,如果主线程被长时间占用,键盘和鼠标的操作就不能处理了,然后程序就变成“无响应”了。所以,需要把耗时的任务放在后台运行,让主线程保证响应用户输入。
  • 在非UI程序中,比如Windows服务,多线程就特别有用了,当等待另一台机器(例如一个应用服务器,数据库服务器,客户端)的响应时,用一个工作线程来等待,让主线程保持畅通。
  • 多线程的另一个用处是在函数中有大量计算时,函数划成多个线程可以在多核的机器上执行更快(可以用Environment.ProcessorCount得到CPU核心数量)。
  • 一个C#程序可以通过两种方式成为多线程:显示地创建线程,或者使用.NET显示创建线程的功能(比如BackgroundWorker,线程池,定时器,远程服务器,WebSerivce或ASP.NET程序),在后面这些情况下,只能是多线程。单线程的web服务器肯定不行。在无状态的web服务器里,多线程是相当简单的。主要的问题是如果处理缓存数据的锁机制。

什么时候不应该用多线程:

多线程也有缺点,最大的问题是会让程序变得复杂,多线程本身并不复杂,复杂在于线程间的交互。能让开发周期变长,以及Bug变多。所以需要把多个线程间的交互设计的尽量简单,或者就别用多线程,除非你可以保证的很好。

过多地在线程间切换和分配内存栈,也会带来CPU资源的消耗,通常,当硬盘IO很多时,只有一两个线程依次执行任务的程序性能要更好,而多个性能同时执行一个任务的性能不怎么样。后面会讨论生产者/消费者模型。


创建和启动线程:

 可以用Thread类的构造函数创建线程,传递一个ThreadStart的代理作为参数,这个代理指向将要执行的函数,以下是这个代理的定义:

public delegate void ThreadStart();

执行Start()函数,线程即开始运行,在函数结束后线程会返回,下面是创建ThreadStart的C#语法:

按 Ctrl+C 复制代码
按 Ctrl+C 复制代码

线程t执行Go函数,同时主线程也调用Go,执行结果是:

hello!
hello!

也可以用C#的语法糖:编译器会自动创建一个ThreadStart的代理。

复制代码
static void Main() {
Thread t = new Thread (Go); // No need to explicitly use ThreadStart
t.Start();
...
}
static void Go() { ... }
复制代码

还有更简单的匿名函数语法:

static void Main() {
Thread t = new Thread (delegate() { Console.WriteLine ("Hello!"); });
t.Start();
}

给ThreadStart传递参数:这种形式只能传递一个参数

public delegate void ParameterizedThreadStart (object obj);
复制代码
class ThreadTest {
static void Main() {
Thread t = new Thread (Go);
t.Start (true); // == Go (true)
Go (false);
}
static void Go (object upperCase) {
bool upper = (bool) upperCase;
Console.WriteLine (upper ? "HELLO!" : "hello!");
}
复制代码

结果:

hello!
HELLO!

如果用匿名函数方式:可以传递多个参数,且也不需要类型转换,

复制代码
static void Main() {
Thread t = new Thread (delegate() { WriteText ("Hello"); });
t.Start();
}
static void WriteText (string text) { Console.WriteLine (text); }
复制代码

还有一种传参的方式是传一个实例过去,而不是传一个静态函数:

复制代码
class ThreadTest {
bool upper;
static void Main() {
ThreadTest instance1 = new ThreadTest();
instance1.upper = true;
Thread t = new Thread (instance1.Go);
t.Start();
ThreadTest instance2 = new ThreadTest();
instance2.Go(); // Main thread – runs with upper=false
}
void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }
复制代码

线程命名:线程有一个Name属性,在调试时很有用。

复制代码
class ThreadNaming {
static void Main() {
Thread.CurrentThread.Name = "main";
Thread worker = new Thread (Go);
worker.Name = "worker";
worker.Start();
Go();
}
static void Go() {
Console.WriteLine ("Hello from " + Thread.CurrentThread.Name);
}
}
复制代码

输出:

Hello from main
Hello from worker

前台线程和后台线程:

默认情况下,线程都是前台线程,意味着任何一个前台线程正在运行,程序就是运行的。而后台线程在所有前台线程终止时也会立即终止。

把线程从前台改为后台,线程在CPU调度器的优先级和状态是不会改变的。

复制代码
class PriorityTest {
static void Main (string[] args) {
Thread worker = new Thread (delegate() { Console.ReadLine(); });
if (args.Length > 0) 
worker.IsBackground = true; worker.Start(); } }
复制代码

如果这个程序执行时不带参数,worker线程默认是前台线程,并且会在ReadLine这一行等着用户输入。同时,主线程退出,但是程序会继续运行,因为ReadLine也是前台线程。如果传了一个参数给Main函数,worker线程的状态则被设置成后台状态,程序几乎会在主线程结束时立即退出--终于ReadLine。当后台线程以这种方式终止时,任何代码都不再执行了,这种代码是不推荐的,所以最好在程序退出前等待所有后台线程,可以用超时时间(Thread.Join)来做。如果因为某些原因worker线程一直不结束,也能终止这个线程,这种情况下最好记录一下日志来分析什么情况导致的。

在Windows Form中被抛弃的前台线程是个潜在的危险,因为程序在主线程结束将要退出时,它还在继续运行。在Windows的任务管理器里,它在应用程序Tab里会消失,但在进程Tab里还在。除非用户显式地结束它。

常见的程序退出失败的可能性就是忘记了前台线程。


线程的优先级:线程的优先级决定了线程的执行时间。

enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

线程的优先级为Highest时,并不意味着线程会实时运行,要想实时运行,进程的优先级也得是High。当你的进程的优先级是High时,如果程序进入了死循环,系统会死锁。这个时候就只有按电源键了。所以,慎用。

最好将实时线程和UI分开在两个线程,并设置成不同的优先级,通过远程或共享内存通信,共享内存需要P/Invoking Win32 API(CreateFileMapping和MapViewOfFile)。


线程的异常处理:线程一旦启动,任何在try/catch/finally范围内创建线程的代码块与try/catch/finally就没有什么关系了。

复制代码
public static void Main() {
try {
new Thread (Go).Start();
}
catch (Exception ex) {
// We'll never get here!
Console.WriteLine ("Exception!");
}
static void Go() { throw null; }
}
复制代码

上例中的try/catch基本没用了,新创建的线程可能是未处理的空引用异常,最好在线程要执行的代码里加异常捕获:

复制代码
public static void Main() {
new Thread (Go).Start();
}
static void Go() {
try {
...
throw null; // this exception will get caught below
...
}
catch (Exception ex) {
Typically log the exception, and/or signal another thread
that we've come unstuck
...
}
复制代码

从.NET2.0开始,线程上任何未处理的异常会导致整个程序挂掉,意味着千万别忽略异常,在线程要执行的函数里,给每个可能异常的代码加上try/catch。这可能有点麻烦,所以,很多人这样处理,用全局的异常处理:

复制代码
using System;
using System.Threading;
using System.Windows.Forms;
static class Program {
static void Main() {
Application.ThreadException += HandleError;
Application.Run (new MainForm());
}
static void HandleError (object sender, ThreadExceptionEventArgs e) {
Log exception, then either exit the app or continue...
}
}
复制代码

Application.ThreadException事件会在代码抛出异常时被触发,这样看起来很完美--可以捕获所有异常,但在worker线程上的异常可能捕获不了,在main函数里的窗体的构造函数,在Windows的消息循环之前就执行了。.NET提供了一个低层的事件捕获全局异常:AppDomain.UnhandledException,它才可以捕获所有异常(UI和非UI的)。虽然它提供了一个很好的方式捕获所有异常并记录异常日志,但是它没有办法阻止程序关系,也没有办法阻止.NET的异常对话框

posted @ 2017-09-25 21:48  万事如意-  阅读(68)  评论(0编辑  收藏