多线程程序设计
多线程程序设计
如果在一个程序中,有多个工作要同时做,可以采用多线程。在Windows操作系统中可以运行多个程序,把一个运行的程序叫做一个进程。一个进程又可以有多个线程,每个线程轮流占用CPU的运行时间,Windows操作系统将时间分为时间片,一个线程使用一个时间片后,操作系统将此线程挂起,将另一个线程唤醒,使其使用下一个时间片,操作系统不断的把线程挂起,唤醒,再挂起,再唤醒,如此反复,由于现在CPU的速度比较快,给人的感觉象是多个线程同时执行。Windows操作系统中有很多这样的例子,例如复制文件时,一方面在进行磁盘的读写操作,同时一张纸不停的从一个文件夹飘到另一个文件夹,这个飘的动作实际上是一段动画,两个动作是在不同线程中完成的,也就是说两个动作是同时完成的。又如Word程序中的拼写检查也是在另一个线程中完成的。每个进程最少有一个线程,叫主线程,是进程自动创建的,每进程可以创建多个线程。
不同语言和操作系统对线程提供了不同支持,编写多线程应用程序的方法也不尽相同。例如,VB6没有提供对线程的支持,程序员不能处理自己的线程。VC++6.0开发人员必须充分理解Windows线程和处理模型的复杂性,同时拥有这种线程模型的强大功能。
.Net Framework提供了一个完整而且功能强大的线程模型,该模型允许编程人员精确控制线程中运行的内容,线程何时退出,以及它将访问多少数据等,但使用比VC++6.0简单。
7.1 线程类(Thread)的属性和方法
线程类在命名空间System.Threading中定义的,因此如果要创建多线程,必须引入命名空间System.Threading。Thread类的常用属性和方法如下:
l 属性Priority:设置线程优先级,有5种优先级类别:AboveNormal(稍高)、BelowNormal(稍低)、Normal(中等,默认值)、Highest(最高)和Lowest(最低)。例如语句myThread.Priority=ThreadPriority.Highest设置线程myThread的优先级为最高。一个线程的优先权并不是越高越好,应考虑到整个进程中所有线程以及其他进程的情况做出最优选择。优先级相同的线程按照时间片轮流运行。优先级高的线程先运行,只有优先级高的线程停止、休眠或暂停时,低优先级的线程才能运行。
l 构造函数:Thread(new ThreadStart(线程中要执行的方法名)),构造函数参数中指定的方法需要程序员自己定义,这个方法完成线程所要完成的任务,退出该方法,线程结束。该方法必须为公有void类型的方法,不能有参数。
l 方法Start():建立线程类对象后,线程处于未启动状态,这个方法使线程改变为就绪状态,如果能获的CPU的运行时间,线程变为运行状态。
l 方法IsAlive():判断线程对象是否存在,=true,线程存在。
l 方法Abort():撤销线程对象。不能撤销一个已不存在的线程对象,因此在撤销一个线程对象前,必须用方法IsAlive()判断线程对象是否存在。
l 静态方法Sleep():线程休眠参数指定的时间,单位为毫秒,此时线程处于休眠状态。线程休眠后,允许其它就绪线程运行。休眠指定时间后,线程变为就绪状态。
l 方法Suspend():该方法使线程变为挂起状态。必须用Resume()方法唤醒挂起线程。
l 方法Resume():该方法使挂起线程变为就绪状态,如果能获的CPU的运行时间,线程变为运行状态。如果线程多次被挂起,仅调用一次Resume()方法就可以把线程唤醒。
7.2 创建线程
例子e7_2:本例使用线程类Thread直接创建一个新的线程,在标签控件中显示该线程运行的时间。在窗体放置4个按钮,单击按钮完成新建、挂起、恢复和停止线程的功能。
(1) 新建项目。在窗体中放置4个按钮和一个标签控件,属性Name分别为:button1、button2、button3、button4和label1,按钮属性Text分别为:新线程、挂起、恢复、撤销。button1属性Enabled=true,其余按钮的属性Enabled=false。
(2) 在Form1.cs头部增加语句:using System.Threading。
(3) 为Form1类定义一个线程类变量:private Thread thread;
(4) 为标题为"新线程"的按钮(button1)增加单击事件处理函数如下:
private void button1_Click(object sender, System.EventArgs e)
{ thread=new Thread(new ThreadStart(fun));//生成线程类对象,fun为自定义方法
label1.Text="0";//运行时间从0开始
thread.Start();//线程变为就绪状态,如能获的CPU运行时间,线程变为运行状态
button1.Enabled=false;//标题为"新线程"的按钮,创建线程后,不允许再创建线程
button2.Enabled=true;//标题为"挂起"的按钮,允许对运行状态的线程挂起
button3.Enabled=false;//标题为"恢复"的按钮,线程未挂起,不能恢复
button4.Enabled=true;// 标题为"撤销"的按钮,允许对运行状态的线程撤销
}
(5) 为标题为"挂起"的按钮(button2)增加单击事件处理函数如下:
private void button2_Click(object sender, System.EventArgs e)
{ thread. Suspend();//线程暂停(挂起)
button1.Enabled=false;
button2.Enabled=false;
button3.Enabled=true;
button4.Enabled=false;
}
(6) 为标题为"恢复"的按钮(button3)增加单击事件处理函数如下:
private void button3_Click(object sender, System.EventArgs e)
{ thread. Resume();//暂停(挂起)线程恢复运行
button1.Enabled=false;
button2.Enabled=true;
button3.Enabled=false;
button4.Enabled=true;
}
(7) 为标题为"撤销"的按钮(button4)增加单击事件处理函数如下:
private void button4_Click(object sender, System.EventArgs e)
{ if(thread.IsAlive)
{ thread.Abort();//撤销线程对象
button1.Enabled=true;
button2.Enabled=false;
button3.Enabled=false;
button4.Enabled=false;
}
}
(8) C#线程模型允许将任何一个void类型的公有方法(静态或非静态)作为线程方法,因此允许在任何一个类(不要求这个类是某个类的子类)中定义线程方法,而且同一个类中可以定义多个线程方法。为Form1类定义一个线程方法如下:
public void fun()//在线程中执行的方法,必须为公有void类型方法,不能有参数。
{ while(true)//退出该方法,线程结束,这里是死循环,线程将一直运行
{ int x=Convert.ToInt32(label1.Text);
x++;
label1.Text=Convert.ToString(x);
Thread.Sleep(1000);//线程休眠1秒钟,休眠一次,线程运行了1秒钟
}
}
(9) 在关闭程序之前,必须撤销线程对象。为主窗体的Closing事件增加事件处理函数如下:
private void Form1_Closing(object sender,System.ComponentModel.CancelEventArgs e)
{ if(thread.IsAlive)
thread.Abort();
}
(10) 编译,运行,单击标题为"新线程"的按钮,新线程开始,计数器从0开始计数。单击标题为"挂起"的按钮,线程暂停,计数器也暂停。单击标题为"恢复"的按钮,线程重新启动,计数器也继续计数。单击标题为"撤销"的按钮,线程对象被撤销,线程对象不存在,计数器停止计数。运行效果如右图。
7.3 建立线程类
有时需要建立多个线程,每个线程要实现的功能基本相同,但有个别参数不同,例如,每个线程完成同样的任务,但控制的对象不同。使用线程类Thread直接创建新线程,线程类构造函数参数为一个方法,在这个方法中实现线程所要求的任务,但该方法不能有参数,因此无法通过方法的参数传递不同设置。为解决这个问题,可以定义一个自己的线程类。具体实现方法见下例。下边的例子用到了进度条(ProgressBar)控件,首先介绍进度条控件。
7.3.1 进度条(ProgressBar)控件
进度条(ProgressBar)控件经常用来显示一个任务的进度。有时,要完成一个长时间的任务,例如一个软件的安装,如果没有任何提示,使用者可能分不清任务是在进行中,还是死机了,可以使用进度条显示安装进度,表示安装正在进行。进度条常用属性如下:
l 属性Maximum:进度条所代表的最大值(整数),默认值100。
l 属性Minimum:进度条所代表的最小值(整数),默认值0。
l 属性Step:变化的步长,默认值为10。
l 属性Value:进度条当前位置代表的值。修改该值,达到一个Step,进度增加一格。
7.3.2 用线程控制进度条
例子e7_3_2:建立两个线程,分别控制两个进度条(ProgressBar)控件,每个进度条的属性Value变化的速率不一样。具体实现步骤如下,运行效果如下图。
(1) 新建项目。在Form1.cs头部增加语句:using System.Threading。
(2) 在窗体中放置2个进度条(ProgressBar)控件。属性Name分别为progressBar1、progressBar2。
(3) 在Form1.cs文件e7_3_2命名空间中,Form1类定义的后边,建立线程类如下:
public class myThread
{ private int SleepTime;//线程的休眠时间,从构造函数赋值
private ProgressBar progressBar;//本线程控制哪个进度条,从构造函数赋值
private Thread thread1;
public myThread(int Time,ProgressBar p1)//构造函数,
{ SleepTime=Time;
progressBar=p1;
thread1=new Thread(new ThreadStart(fun));
thread1.Start();
}
public void fun()//在线程中执行的方法,必须为公有void类型方法,不能有参数。
{ while(progressBar.Value!=100)
{ progressBar.Value+=1;
Thread.Sleep(SleepTime);
}
}//退出该方法,线程结束
}
(4) 为Form1类增加变量:myThread myThread1,myThread2。
(5) 为Form1类构造函数增加语句如下:
myThread1=new myThread(100,progressBar1);
myThread2=new myThread(200,progressBar2);
(6) 编译,运行,可以看到两个进度条以不同的速度前进,当进度条被添满,线程停止。
7.4 多个线程互斥
多个线程同时修改共享数据可能发生错误。假设2个线程分别监视2个入口进入的人数,每当有人通过入口,线程用C#语句对总人数变量执行加1操作。一条C#语句可能包含若干机器语言语句,假设C#语句加1操作包含的机器语言语句是:先取总人数,加1,再存回总人数。操作系统可以在一条机器语言语句结束后,挂起运行的线程。如当前总人数为5,线程1运行,监视到有人通过入口,取出总人数(此时为5)后,线程1时间用完挂起。线程2唤醒,也监视到有人通过入口,并完成了总人数加1并送回的操作,总人数为6,线程2挂起。线程1唤醒,对已取出的总人数(此时为5)加1,存回总人数,总人数应为7,实际为6,少算一个。为了防止此类错误,在一个线程修改共享资源(例如上例的总人数变量)时,不允许其它线程对同一共享资源进行修改,这叫线程的互斥。这样的实例很多,例如计算机中的许多外设,网络中的打印机等都是共享资源,只允许一个进程或线程使用。
7.4.1 多个线程同时修改共享数据可能发生错误
例子e7_4_1:下边的例子模拟2个线程同时修改同一个共享数据时可能发生的错误。
(1) 新建项目。在Form1.cs头部增加语句:using System.Threading。
(2) 在窗体中放置一个标签控件,属性Name=label1。
(3) 为Form1类定义2个线程类变量:Thread thread1,thread2。定义整形变量:int num=0。
(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()//在线程中执行的方法,必须为公有void类型方法,不能有参数。
{ int k,n;
for(k=0;k<4;k++)
{ n=num;//取出num,可以把把num想象为总人数
n++;//加1
Thread.Sleep(10);//模拟复杂的费时运算,在此期间,有可能时间片用完
num=n;//存回num
Thread.Sleep(50);
}
label1.Text=Convert.ToString(num);
}//退出该方法,线程结束
public void Fun2()
{ int k,n;
for(k=0;k<4;k++)
{ n=num;
n++;
Thread.Sleep(10);
num=n;
Thread.Sleep(100);
}
label1.Text=Convert.ToString(num);
}
(6) 编译,运行,标签控件应显示8,实际运行多次,显示的数要小于8。
7.4.2 用Lock语句实现互斥
Lock语句的形式如下:lock(e){访问共享资源的代码}。其中e指定要锁定的对象,必须是引用类型,一般为this,即Lock语句所在类的对象。Lock语句将访问共享资源的代码标记为临界区。临界区的意义是:假设线程1正在执行e对象的临界区中的代码时,如其它线程也要求执行这个e对象的任何临界区中代码,将被阻塞,一直到线程1退出临界区。
例子e7_4_2:用C#语句Lock实现互斥。修改例子e7_4_1中的Fun1()和Fun2()方法如下:
public void Fun1()//在线程中执行的方法,必须为公有void类型方法,不能有参数。
{ 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);
}
label1.Text=Convert.ToString(num);
}//退出该方法,线程结束
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);
}
label1.Text=Convert.ToString(num);
}
编译,运行,标签控件显示8。如果有多个共享数据区,使用此方法不太方便。
7.4.3 用Mutex类实现互斥
可以使用Mutex类对象保护共享资源(如上例中的总人数变量)不被多个线程同时访问。Mutex类WaitOne方法和ReleaseMutex方法之间代码是互斥体,这些代码要访问共享资源。Mutex类的WaitOne方法分配互斥体访问权,该方法只向一个线程授予对互斥体的独占访问权。如果一个线程获取了互斥体,则要获取该互斥体的第二个线程将被挂起,直到第一个线程用ReleaseMutex方法释放该互斥体。
例子e7_4_3:使用Mutex类对象实现互斥。修改例子e7_4_1,为Form1类增加私有Mutex类变量:private Mutex mut。在Form1类构造函数中建立Mutex类对象,在建立线程语句之前增加语句mut=new Mutex();修改例子e7_4_1中的两个Fun1()和Fun2()方法如下:
public void Fun1()//在线程中执行的方法,必须为公有void类型方法,不能有参数。
{ 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);
}
label1.Text=Convert.ToString(num);
}//退出该方法,线程结束
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);
}
label1.Text=Convert.ToString(num);
}
编译,运行,标签控件显示8。如果有多个共享数据区,可以定义多个Mutex类对象。
7.4.4 用Monitor类实现互斥
也可以使用Monitor类保护共享资源不被多个线程或进程同时访问。Monitor类通过向单个线程授予对象锁来控制对对象的访问。只有拥有对象锁的线程才能执行临界区的代码,此时其它任何线程都不能获取该对象锁。只能使用Monitor类中的静态方法,不能创建Monitor类的实例。Monitor类中的静态方法主要有:
l 方法Enter:获取参数指定对象的对象锁。此方法放在临界区的开头。如其它线程已获取对象锁,则该线程将被阻塞,直到其它线程释放对象锁,才能获取对象锁。
l 方法Wait:释放参数指定对象的对象锁,以便允许其它被阻塞的线程获取对象锁。该线程进入等待状态,等待状态必须由其它线程用方法Pulse或PulseAll唤醒,使等待状态线程变为就绪状态。
l 方法Pulse和PulseAll:向等待线程队列中第一个或所有等待参数指定对象的对象锁的线程发送信息,占用对象锁的线程准备释放对象锁。执行方法Exit后将释放对象锁。
l 方法Exit:释放参数指定对象的对象锁。此操作还标记受对象锁保护的临界区的结尾。
使用Monitor类实现互斥也很简单,请读者修改例子7_4_1,使用Monitor类实现互斥。Monitor类主要用来实现生产者和消费者关系中的线程的同步,具体例子见下一节。
7.5 生产者线程和消费者线程的同步
在生产者和消费者关系中,生产者线程产生数据,并把数据存到公共数据区,消费者线程使用数据,从公共数据区取出数据,并进行分析。很显然,如果公共数据区只能存一个数据,那么在消费者线程取出数据前,生产者线程不能放新数据到公共数据区,否则消费者线程将丢失数据。同样,只有在生产者线程把数据已经放到公共数据区,消费者线程才能取出数据,如果新数据未放到公共数据区,消费者线程不能取数据。这些就是所谓的生产者和消费者关系,必须要求生产者线程和消费者线程同步。
7.5.1 生产者线程和消费者线程不同步可能发生错误
例子e7_5_1:下边的例子模拟生产者线程和消费者线程不同步可能发生错误。有一个公共变量,要求生产者线程顺序放1到4到这个公共变量中,每放一个变量,消费者线程取出这个数求和,最后把和显示出来,显然和应为10。如不采取同步措施,和的结果不正确。
(1) 新建项目。在Form1.cs头部增加语句:using System.Threading。
(2) 在窗体中放置一个标签控件,属性Name=label1。
(3) 为Form1类定义2个线程类变量:Thread thread1,thread2。
(4) 为Form1类定义2个整形变量:int sum=0,x=-1。
(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()//生产数据
{ int k,n;
for(k=1;k<5;k++)
{ x=k;
Thread.Sleep(200);
}
}
public void Fun2()//消费数据
{ int k,n;
for(k=0;k<4;k++)
{ sum+=x;
Thread.Sleep(100);
}
label1.Text=Convert.ToString(sum);
}
(7) 编译,运行,标签控件应显示10,实际运行多次,显示的数不为10。
7.5.2 生产者线程和消费者线程同步的实现
修改上例,为Form1类定义1个布尔变量:bool mark=false。其值为false,表示数据还未放到公共数据区(即x)中,生产者线程可以放数据到公共数据区中,由于没有数据,消费线程不能取数据,必须等待。mark=true,表示数据已放到公共数据区(即x)中,消费线程还未取数据,生产者线程不能再放数据到公共数据区中,必须等待。由于有了数据,消费线程可以取数据。修改Fun1()如下:
public void Fun1()//生产数据
{ int k,n;
for(k=1;k<5;k++)
{ Monitor.Enter(this);//这里this是Form1类对象,得到this的对象锁
if(mark)//Monitor.Enter(this)和Monitor.Exit(this)是临界区
Monitor.Wait(this);//如消费者数据未取走,释放对象锁,生产者等待
Mark=!mark;
x=k;
Monitor.Pulse(this);//激活消费者线程
Monitor.Exit(this);//释放this的对象锁
}
}
修改Fun2()如下:
public void Fun2()//消费数据
{ int k,n;
for(k=0;k<4;k++)
{ Monitor.Enter(this);
if(!mark)
Monitor.Wait(this);//如果生产者未放数据,消费者等待
Mark=!mark;
sum+=x;
Monitor.Pulse(this);
Monitor.Exit(this);
}
label1.Text=Convert.ToString(sum);
}
编译,运行,标签控件应显示10。