E N D
第七章 多线程程序设计 7.1 创建线程 7.2 多个线程互斥 7.3 生产者线程和消费者线程的同步
如果在一个程序中,有多个工作要同时做,可以采用多线程。在Windows操作系统中可以运行多个程序,把一个运行的程序叫做一个进程。一个进程又可以有多个线程,每个线程轮流占用CPU的运行时间,Windows操作系统将时间分为时间片,一个线程用完一个时间片后,操作系统将此线程挂起,将另一个线程唤醒,使其使用下一个时间片,操作系统不断的把线程挂起,唤醒,再挂起,再唤醒,如此反复,由于现在CPU的速度比较快,给人的感觉象是多个线程同时执行。Windows操作系统中有很多这样的例子,例如复制文件时,一方面在进行磁盘的读写操作,同时一张纸不停的从一个文件夹飘到另一个文件夹,这个飘的动作实际上是一段动画,两个动作是在不同线程中完成的,就像两个动作是同时进行的。又如Word程序中的拼写检查也是在另一个线程中完成的。每个进程最少有一个线程,叫主线程,是进程自动创建的,每进程可以创建多个线程。如果在一个程序中,有多个工作要同时做,可以采用多线程。在Windows操作系统中可以运行多个程序,把一个运行的程序叫做一个进程。一个进程又可以有多个线程,每个线程轮流占用CPU的运行时间,Windows操作系统将时间分为时间片,一个线程用完一个时间片后,操作系统将此线程挂起,将另一个线程唤醒,使其使用下一个时间片,操作系统不断的把线程挂起,唤醒,再挂起,再唤醒,如此反复,由于现在CPU的速度比较快,给人的感觉象是多个线程同时执行。Windows操作系统中有很多这样的例子,例如复制文件时,一方面在进行磁盘的读写操作,同时一张纸不停的从一个文件夹飘到另一个文件夹,这个飘的动作实际上是一段动画,两个动作是在不同线程中完成的,就像两个动作是同时进行的。又如Word程序中的拼写检查也是在另一个线程中完成的。每个进程最少有一个线程,叫主线程,是进程自动创建的,每进程可以创建多个线程。
Process类 Process类 • Process类位于System.Diagnostics名称空间下,它专门用于完成系统进程的管理任务。 可以在本地计算机上启动和停止进程,也可以向进程查询特定类型的信息。在远程计算机上,无法启动和停止进程,但可以查询进程的相关信息。在对进程进行操作时,首先要创建Process类的实例,其次还需要设置其对象成员的StartInfo属性,最后调用它的Start方法。
例.启动、停止和观察进程 1. 新建一个名为ProcessExample的Windows应用程序。 2. 从工具箱中将Process组件拖放到设计窗体。 3. 添加名称空间 : using System.Diagnostics; using System.Threading; 4. 添加“启动记事本”、“停止记事本”和“观察所有进程”三个按钮,并添加Click事件代码: private void buttonStart_Click(object sender, EventArgs e) { process1.StartInfo.FileName = "notepad.exe"; //启动Notepad.exe进程. process1.Start(); }
private void buttonStop_Click(object sender, EventArgs e) { //创建新的Process组件的数组,并将它们与指定的进程名称(Notepad)的所有进程资源相关联. Process[] myprocesses; myprocesses = Process.GetProcessesByName("Notepad"); foreach (Process instance in myprocesses) { //设置终止当前线程前等待1000毫秒 instance.WaitForExit(1000); instance.CloseMainWindow(); } } private void buttonView_Click(object sender, EventArgs e) { listBox1.Items.Clear(); //创建Process类型的数组,并将它们与系统内所有进程相关联
Process[] processes; processes = Process.GetProcesses(); foreach (Process p in processes) { //由于访问Idle的StartTime会出现异常,所以将其排除在外 if (p.ProcessName != "Idle") { //将每个进程名和进程开始时间加入listBox1中 this.listBox1.Items.Add( string.Format("{0,-30}{1:h:m:s}", p.ProcessName, p.StartTime)); } } }
超级链接到微软网站 : System.Diagnostics.Process.Start("http://www.micosoft.com.cn" 打开一个窗口,列出C盘根目录下的文件及文件夹: System.Diagnostics.Process.Start("C:/");
7.1 创建线程 本节介绍线程类(Thread)的属性和方法以及如何创建线程。
7.1.1 线程类(Thread)的属性和方法 线程类在命名空间System.Threading中定义的,因此如果要创建多线程,必须引入命名空间System.Threading。Thread类的常用属性和方法如下: • 属性Priority:设置线程优先级,有5种优先级类别:AboveNormal(稍高)、BelowNormal(稍低)、Normal(中等,默认值)、Highest(最高)和Lowest(最低)。例如语句myThread.Priority=ThreadPriority.Highest设置线程myThread的优先级为最高。优先级高的线程先运行,只有优先级高的线程停止、休眠或暂停时,低优先级的线程才能运行。
构造函数:Thread(new ThreadStart(线程中要执行的无参数方法名)),参数中指定的方法需要程序员自己定义,这个方法完成线程所要完成的任务,退出该方法,线程结束。该方法必须为公有void类型的方法,无参数。如果希望有参数,可使用C#2.0中新构造函数:Thread(new ParameterizedThreadStart(线程中要执行的有参数方法名))。 • 方法Start():建立线程类对象后,线程处于未启动状态,这个方法使线程改变为就绪状态,如果能获的CPU的运行时间,线程变为运行状态。 • 方法IsAlive():判断线程对象是否存在,=true,线程存在。
方法Abort():撤销线程对象。不能撤销一个已不存在的线程对象,因此在撤销一个线程对象前,必须用方法IsAlive()判断线程对象是否存在。方法Abort():撤销线程对象。不能撤销一个已不存在的线程对象,因此在撤销一个线程对象前,必须用方法IsAlive()判断线程对象是否存在。 • 静态方法Sleep():线程休眠参数设定的时间,单位为毫秒,此时线程处于休眠状态。线程休眠后,允许其他就绪线程运行。休眠指定时间后,线程变为就绪状态。 • 方法Suspend()和Resume():Suspend()方法使线程变为挂起状态。Resume方法使挂起线程变为就绪状态,如能获的CPU的运行时间,线程变为运行状态。如线程多次被挂起,调用一次Resume()方法就可以把线程唤醒。由于不安全C#2.0建议不使用这两个函数。
7.1.2 创建线程 【例7.1】本例使用线程类Thread创建一个新的线程,在标签控件中显示该线程运行的时间。在窗体放置2个按钮,单击按钮完成新建和停止线程的功能。 • 新建项目。在窗体中放置2个按钮和1个标签控件(label1)。button1的属性Text=“新线程”, Enabled=true。button2的属性Text=“撤销”, Enabled=false。 • 在Form1.cs头部增加语句: using System.Threading。 • 为Form1类中声明一个代表类dFun、定义一个类dFun的变量和线程类变量:
delegate void dFun(string text); dFun dFun1; //dFun类变量 private Thread thread; //线程类变量 • 为标题为“新线程”的按钮(button1)增加单击事件处理函数如下: private void button1_Click(object sender, EventArgs e) { thread=new Thread(new ThreadStart(fun)); label1.Text="0"; //运行时间从0开始 thread.Start(); button1.Enabled=false; button2.Enabled=true; }
为标题为“撤销”的按钮(button2)增加单击事件处理函数如下:为标题为“撤销”的按钮(button2)增加单击事件处理函数如下: private void button2_Click(object sender, EventArgs e) { if(thread.IsAlive) { thread.Abort(); //撤销线程对象 button1.Enabled=true; button2.Enabled=false; } }
C#线程模型允许将任何一个void类型的公有方法(静态或非静态)作为线程方法,因此允许在任何一个类(不要求这个类是某个类的子类)中定义线程方法,而且同一个类中可以定义多个线程方法。C#2.0不允许在此函数中直接修改线程外控件属性,这是防止多个线程同时修改同一控件的同一属性发生错误,必须使用控件的Invoke方法修改线程外控件属性,Invoke方法有两个参数,参数1是修改控件属性的方法的代表, 参数2是object数组,是传递给参数1代表的方法的参数。为Form1类定义一个线程方法如下:
public void fun() { while(true)//退出该方法,线程结束 { int x=Convert.ToInt32(label1.Text); x++; string s=Convert.ToString(x); label1.Invoke(dFun1,new object[]{s}); Thread.Sleep(1000); //线程休眠1秒钟 } } • 为Form1类定义一个修改label1.Text的方法如下:
private void SetText(string text) { label1.Text = text; } • 在Form1类的构造函数的最后增加如下语句: dFun1=new dFun(SetText); • 在关闭程序之前,必须撤销线程对象。为主窗体的Closing事件增加事件处理函数如下: private void Form1_FormClosing(object sender, FormClosingEventArgs e) { if(thread.IsAlive) thread.Abort(); } • 编译运行,单击标题为"新线程"的按钮,新线程开始,计数器从0开始计数。单击标题为"撤销"的按钮,线程对象被撤销,计数器停止计数。
7.1.3 进度条(ProgressBar)控件 进度条(ProgressBar)控件经常用来显示一个任务的进度。有时,要完成一个长时间的任务,例如一个软件的安装,如果没有任何提示,使用者可能分不清任务是在进行中,还是死机了,可以使用进度条显示安装进度,表示安装正在进行。进度条常用属性如下: • 属性Maximum和Minimum:进度条代表的最大值和最小值(整数),默认值分别为100,0。 • 属性Step:变化的步长,默认值为10。 • 属性Value:进度条当前位置代表的值。修改该值,达到一个Step,进度增加一格。
7.1.3 用线程控制进度条 有时需要建立多个线程,每个线程要实现的功能基本相同,但有个别参数不同,例如,每个线程完成同样的任务,但控制的对象不同。可使用C#2.0中定义的线程类构造函数Thread(new ParameterizedThreadStart(…))创建新线程,…处为一个实现线程所要求任务的方法,该方法只允许有1个object类型参数,可用参数传递不同对象。实现方法见下例。 【例7.2】建立两个线程,分别控制两个进度条(ProgressBar)控件,每个进度条的属性Value变化的速率不一样。具体实现步骤如下,运行效果如图。
新建项目。在Form1.cs头部增加语句:using System.Threading。 • 在窗体中放置2个进度条(ProgressBar)控件。属性Name分别为ProgressBar1、ProgressBar2。 • 为Form1类中声明一个结构、代表类dFun、定义一个类dFun的变量和线程类变量: 结构定义,由于要传递两个参数,定义结构代表两个参数 struct Fargs { public ProgressBar PB;//线程控制的对象 public int SleepT; } //线程休眠时间
delegate void dFun(ProgressBar p); dFun dFun1; //Fun类变量 private Thread thread1; //线程类变量 private Thread thread2; Fargs Frags1; //结构变量 • 为Form1类定义一个线程方法如下(注意只能有一个object类参数): public void fun(object data) { Fargs Frags2 = (Fargs)data; ProgressBar p1 = Frags2.PB; int SleepTime = Frags2.SleepT; while (p1.Value <100) { p1.Invoke(dFun1, new object[] { p1 }); Thread.Sleep(SleepTime); } } 5.为Form1类定义一个修改进度条的属性Value的方法如下:
private void SetValue(ProgressBar p2) { p2.Value += 1; } 6.在Form1类的Load事件增加事件函数如下(此处代码不能放在构造函数中): private void Form1_Load(object sender, EventArgs e) { dFun1 = new dFun(SetValue); thread1 = new Thread(new ParameterizedThreadStart(fun)); thread2 = new Thread(new ParameterizedThreadStart(fun)); Frags1.PB = progressBar1; Frags1.SleepT = 100; thread1.Start(Frags1); //注意如何为fun方法传递参数 Frags1.PB = progressBar2; Frags1.SleepT = 200; thread2.Start(Frags1); }
7.为主窗体的Closing事件增加事件处理函数如下:7.为主窗体的Closing事件增加事件处理函数如下: private void Form1_FormClosing(object sender, FormClosingEventArgs e) { if(thread1.IsAlive) thread1.Abort(); if(thread2.IsAlive) thread2.Abort(); } 8.编译,运行,可以看到两个进度条以不同的速度前进,当进度条被添满,线程停止。
7.1.5 BackgroundWorker组件 Windows应用程序如需要执行长时间的后台操作(例如Word程序的拼写检查),可以建立一个优先级最低的线程完成这样的工作。编写这样线程代码可能是一项艰巨而又耗时的工作。在VS2005中,可以使用BackgroundWorker控件简化编程。控件常用属性和方法如下: • 方法RunWorkerAsync(object):启动线程,参数将传递给线程DoWork事件处理函数。 • 方法CancelAsync():结束线程。 • 事件DoWork:希望在线程中完成的工作代码放到此事件处理函数中。 • 事件RunWorkerCompleted:线程结束引发的事件。通过事件函数参数2获得退出的原因。
7.2 多个线程互斥 多个线程同时修改共享数据可能发生错误。假设2个线程分别监视2个入口进入的人数,每当有人通过入口,线程用C#语句对总人数变量执行加1操作。一条C#语句可能包含若干机器语言语句,假设C#语句加1操作包含的机器语言语句是:取总人数,加1,再存回。操作系统可以在一条机器语言语句结束后,挂起运行的线程。如当前总人数为5,线程1运行,监视到有人通过入口,取出总人数(=5)后,线程1时间用完挂起。线程2唤醒,也监视到有人通过入口,并完成了总人数加1并送回的操作,总人数为6,线程2挂起。线程1唤醒,对已取出的总人数(此时为5)加1,存回去,总人数应为7,实为6,少算一个。为了防止此类错误,在一个线程修改共享资源(例如上例的总人数变量)时,不允许其他线程对同一共享资源进行修改,这叫线程的互斥。这样的实例很多,例如计算机中的许多外设,网络中的打印机等都是共享资源,只允许一个进程或线程使用。
7.2.1多个线程同时修改共享数据可能发生错误 【例7.4】下边的例子模拟2个线程同时修改同一个共享数据时可能发生的错误。 1.新建项目。在Form1.cs头部增加语句: using System.Threading。 2.为Form1类定义2个线程类变量:Thread thread1,thread2。定义整形变量:int num=0。 3.在窗体中放置一个标签和按钮控件,按钮的事件处理函数如下: private void button1_Click(object sender, EventArgs e) { label1.Text = num.ToString(); } 4.为Form1类构造函数增加语句如下: thread1= new Thread(new ThreadStart(Fun1)); thread2= new Thread(new ThreadStart(Fun2)); thread1.Start(); thread2.Start();
5.为Form1类中定义Fun1()和Fun2()方法如下: public void Fun1() { int k,n; for(k=0;k<4;k++) { n=num;//取出num,可以把把num想象为总人数 n++; //加1 //模拟复杂的费时运算,在此期间,有可能时间片用完 Thread.Sleep(20); num=n;//存回num Thread.Sleep(50); } }//退出该方法,线程结束
public void Fun2() { int k,n; for(k=0;k<4;k++) { n=num; n++; Thread.Sleep(10); num=n; Thread.Sleep(100); } } 6.编译运行,单击按钮,标签控件应显示8,实际运行多次,显示的数要小于8。
7.2.2 用Lock语句实现互斥 Lock语句的形式如下:lock(e){访问共享资源的代码}。其中e指定要锁定的对象,锁定该对象内所有临界区,必须是引用类型,一般为this。Lock语句将访问共享资源的代码标记为临界区。临界区的意义是:假设线程1正在执行e对象的临界区中的代码时,如其他线程也要求执行这个e对象的任何临界区中代码,将被阻塞,一直到线程1退出临界区。 【例7.5】用C#语句Lock实现互斥。修改例7.4中的Fun1()和Fun2()方法如下:
public void Fun1() { int k,n; for(k=0;k<4;k++) { lock(this) //这里的this是Form1类的对象 { n=num; //这对大括号中代码为this的临界区 n++;//this的临界区包含两部分,函数Fun1和Fun2中的临界区 Thread.Sleep(10); num=n; } Thread.Sleep(50); } } //退出该方法,线程结束
public void Fun2() { int k,n; for(k=0;k<4;k++) {//如有线程进入此临界区,其他线程就不能进入这个临界区 lock(this) { n=num; //也不能进入前边的临界区 n++; Thread.Sleep(10); num=n; } Thread.Sleep(100); } } 编译运行,单击按钮标签控件应显示8。如果有多个共享数据区,使用此方法不太方便。
7.2.3 用Mutex类实现互斥 可以使用Mutex类对象保护共享资源(如上例中的总人数变量)不被多个线程同时访问。Mutex类WaitOne方法和ReleaseMutex方法之间代码是互斥体,这些代码要访问共享资源。Mutex类的WaitOne方法分配互斥体访问权,该方法只向一个线程授予对互斥体的独占访问权。如果一个线程获取了互斥体,则要获取该互斥体的第二个线程将被挂起,直到第一个线程用ReleaseMutex方法释放该互斥体。 【例7.6】使用Mutex类对象实现互斥。修改例7.4,为Form1类增加私有Mutex类变量:private Mutex mut。在Form1类构造函数中增加语句:mut=new Mutex();该句位置必须在建立线程语句之前。修改例7.4中的两个Fun1()和Fun2()方法如下:
public void Fun1() { int k,n; for(k=0;k<4;k++) { mut.WaitOne(); //等待互斥体访问权 n=num;// mut.WaitOne()和mut.ReleaseMutex()之间是互斥体 n++;//Mutex类对象mut的互斥体包含两部分,函数Fun1和Fun2中的互斥体 Thread.Sleep(10);//有线程进入一个互斥体,其他线程不能进入任何一个互斥体 num=n; mut.ReleaseMutex(); //释放互斥体访问权 Thread.Sleep(50); } }//退出该方法,线程结束
public void Fun2() { int k,n; for(k=0;k<4;k++) { mut.WaitOne(); n=num; n++; Thread.Sleep(10); num=n; mut.ReleaseMutex(); Thread.Sleep(100); } } 编译,运行,标签控件显示8。如果有多个共享数据区,可以定义多个Mutex类对象。
7.2.4 用Monitor类实现互斥 也可以使用Monitor类保护共享资源不被多个线程或进程同时访问。Monitor类通过向单个线程授予对象锁来控制对对象的访问。只有拥有对象锁的线程才能执行临界区的代码,此时其他任何线程都不能获取该对象锁。只能使用Monitor类中的静态方法,不能创建Monitor类的实例。Monitor类中的静态方法主要有: • 方法Enter:获取参数指定对象的对象锁。此方法放在临界区的开头。如其他线程已获取对象锁,则该线程将被阻塞,直到其他线程释放对象锁,才能获取对象锁。
方法Wait:释放参数指定对象的对象锁,以便允许其他被阻塞的线程获取对象锁。该线程进入等待状态,等待状态必须由其他线程用方法Pulse或PulseAll唤醒,使等待状态线程变为就绪状态。方法Wait:释放参数指定对象的对象锁,以便允许其他被阻塞的线程获取对象锁。该线程进入等待状态,等待状态必须由其他线程用方法Pulse或PulseAll唤醒,使等待状态线程变为就绪状态。 • 方法Pulse和PulseAll:向等待线程队列中第一个或所有等待参数指定对象的对象锁的线程发送信息,占用对象锁的线程准备释放对象锁。执行方法Exit后将释放对象锁。 • 方法Exit:释放参数指定对象的对象锁。此操作还标记受对象锁保护的临界区的结尾。 • 使用Monitor类实现互斥也很简单,请读者修改例7_2_1,使用Monitor类实现互斥。Monitor类主要用来实现生产者和消费者关系中的线程的同步,具体例子见下一节。
7.3 生产者线程和消费者线程的同步 在生产者和消费者关系中,生产者线程产生数据,并把数据存到公共数据区,消费者线程使用数据,从公共数据区取出数据。显然如果公共数据区只能存一个数据,那么在消费者线程取出数据前,生产者线程不能放新数据到公共数据区,否则消费者线程将丢失数据。同样只有生产者线程把数据已经放到公共数据区,消费者线程才能取出数据,否则消费者线程不能取数据。这些就是所谓的生产者和消费者关系,必须要求生产者线程和消费者线程同步。
7.3.1 生产者线程和消费者线程不同步可能发生错误 【例7.7】下边的例子模拟生产者线程和消费者线程不同步可能发生错误。有一个公共变量,要求生产者线程顺序放1到4到这个公共变量中,每放一个变量,消费者线程取出这个数求和,最后把和显示出来,显然和应为10。如不采取同步措施,和的结果不正确。 1.新建项目。在Form1.cs头部增加语句: using System.Threading。 2.为Form1类定义2个线程类变量:Thread thread1,thread2。 3.为Form1类定义2个整形变量:int sum=0,x=-1。 4.在窗体中放置一个标签和按钮控件,按钮的事件处理函数如下: private void button1_Click(object sender, EventArgs e) { label1.Text = sum.ToString(); } 5.为Form1类构造函数增加语句如下:
thread1= new Thread(new ThreadStart(Fun1)); thread2= new Thread(new ThreadStart(Fun2)); thread1.Start(); thread2.Start(); 6.为Form1类定义Fun1()和Fun2()方法如下: public void Fun1() //生产数据 { for(int k=1;k<5;k++) { x=k; Thread.Sleep(200); } } public void Fun2() //消费数据 { for(int k=0;k<4;k++) { sum+=x; Thread.Sleep(100); } } 7.编译运行,单击按钮,标签控件应显示10,实际运行多次,显示的数不为10。
7.3.2生产者线程和消费者线程同步的实现 修改上例,为Form1类定义1个布尔变量:bool mark=false。其值为false,表示数据还未放到公共数据区(即x)中,生产者线程可以放数据到公共数据区中,由于没有数据,消费线程不能取数据,必须等待。mark=true,表示数据已放到公共数据区(即x)中,消费线程还未取数据,生产者线程不能再放数据到公共数据区中,必须等待。由于有了数据,消费线程可以取数据。修改Fun1()如下:
public void Fun1() //生产数据 { for(int k=1;k<5;k++) {//这里this是Form1类对象,得到this的对象锁 Monitor.Enter(this); //Monitor.Enter(this)和Monitor.Exit(this)是临界区 if(mark) //如消费者数据未取走,释放对象锁,生产者等待 Monitor.Wait(this); mark=!mark; x=k; Monitor.Pulse(this); //激活消费者线程 Monitor.Exit(this); }//释放this的对象锁} }
修改Fun2()如下: public void Fun2() //消费数据 { for(int k=0;k<4;k++) { Monitor.Enter(this); if(!mark) Monitor.Wait(this);//如果生产者未放数据,消费者等待 mark=!mark; sum+=x; Monitor.Pulse(this); Monitor.Exit(this); } } 编译,运行,单击按钮,标签控件应显示10。