|
|
|
|
 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:44 | 15,699 byte(s)]
 Re: 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:49 | 10,729 byte(s)]
 Re: 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:51 | 8,905 byte(s)]
 Re: 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:52 | 7,776 byte(s)]
 Re: 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:55 | 11,708 byte(s)]
 Re: 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:57 | 14,167 byte(s)]
 Re: 下载--Wrox红皮书《C#高级编程(第3版)》 - zhyeit422121 [ 2005-11-23 13:58 | 6,407 byte(s)]
|
|
|
|
[Original]
[Print]
[Top]
|
C#中的线程
本文摘至清华大学出版社出版的Wrox红皮书《C#高级编程(第3版)》,转载必须标明出处
本文介绍C#和.NET基类为开发多线程应用程序所提供的支持。我们将简要介绍Thread类以及各种线程支持,再用两个示例来说明线程的规则。然后论述线程同步时会出现的问题。由于这个主题非常复杂,所以本节的重点是理解一些基本规则,而不是开发真实的应用程序。本文的主要内容如下:
● 如何开始一个线程
● 提供线程的优先级
● 通过同步控制对对象的访问
学习完本文,就可以在自己的代码中处理线程了。下面首先了解线程的基础知识。
15.1 线程
线程是程序中的执行序列。使用C#编写任何程序时,都有一个入口:Main()方法。程序从Main()方法的第一条语句开始执行,直到这个方法返回为止。
这个程序结构非常适合于有一个可识别的任务序列的程序,但程序常常需要同时完成多个任务。例如启动Internet Explorer,并为某些页面需要越来越多的时间加载而烦恼。最终(可能就在2秒钟后),用户会单击Back按钮,或者键入其他的URL查看其他的页面。为此,Internet Explorer必须至少做3件事:
● 从Internet返回时,把页面的数据和附带的文件收集到垃圾箱中
● 显示页面
● 查看用户希望IE执行其他任务的输入内容(例如查看按钮的单击)
这种情况也会发生在下述场合下:程序在执行某个任务,同时显示一个对话框,用户可以在这个对话框中随时取消这个任务。
下面更详细地讨论Internet Explorer示例。为了简化问题,我们忽略存储来自Internet的数据的任务,并假定Internet Explorer只有两个任务:
● 显示页面
● 查看用户的输入
假定这个Web页面需要较长的时间才能显示,其中有一些处理器密集型的JavaScript,或者包含需要持续更新的选取框元素。处理这种情况的一个方式是编写一个方法,它在显示页面的过程中,还执行其他工作。过一会儿,假定是20分之一秒,该方法将检查是否有用户输入。如果有,就处理该用户的输入(这会取消显示任务)。否则,该方法就在下一个20分之一秒内显示页面。
这个方法是有效的,但要执行一个非常复杂的方法。更糟糕的是,它将完全忽略Windows基于事件的结构。如果在系统中有任何用户输入,就会通知应用程序产生了一个事件。下面修改这个方法,让Windows使用事件:
● 编写一个响应用户输入的事件处理程序。该响应应包括设置一些标志,表示显示页面的过程停止。
● 编写一个方法处理显示任务,这个方法用于系统在没有做其他事情时显示页面。
这种解决方案比较好,因为它利用了Windows事件结构。下面看看这个方法要完成的工作:从一开始就必须仔细考虑时间。在这个方法运行时,计算机不能响应任何用户输入。即这个方法必须知道自己被调用的时间,在工作过程中一直监视着时间,一旦过去指定的时间(留给用户响应的时间略小于10分之一秒),就必须返回。而且,在这个方法返回前,还需要存储当前的状态,这样,在下一次调用时,才知道应从哪里开始。这样的方法是肯定可以编写出来的,过去使用Windows 3.1时,就必须这样处理。NT 3.1和后来的Windows 95引入了多线程处理,以更方便的方式解决该问题。
15.2 多线程应用程序
上面的示例说明了应用程序需要处理多个任务的情形,所以最明显的解决方案是给应用程序提供多个执行线程。线程表示计算机执行的指令序列。应用程序不应只有一个这样的序列,实际上,应用程序可以有任意多个线程。每次创建一个新执行线程时,都需要指定从哪个方法开始执行。应用程序中的第一个线程总是Main()方法,因为第一个线程是由.NET运行库开始执行的,Main()方法是.NET运行库选择的第一个方法。后续的线程由应用程序在内部启动,即应用程序可以选择启动哪个线程。
多线程的工作方式
我们仅讨论了同时执行的线程。实际上,一个处理器在某一刻只能处理一个任务。如果有一个多处理器系统,理论上它可以同时执行多个指令——一个处理器执行一个指令,但大多数人使用的是单处理器计算机,这种情况是不可能同时发生的。而实际上,Windows操作系统表面上可以同时处理多个任务,这个过程称为抢先式多任务处理(pre-emptive multitasking)。
所谓抢先式多任务处理,是指Windows在某个进程中选择一个线程,该线程运行一小段时间。Microsoft没有说明这段时间有多长,因为为了获得最好的性能,Windows有一个内部操作系统参数来控制这个时间值。但在运行Windows应用程序时,用户不需要知道它。从我们的角度来看,这个时间非常短,肯定不会超过几毫秒。这段很短的时间称为线程的时间片(time slice)。过了这个时间片后,Windows就收回控制权,选择下一个被分配了时间片的线程。这些时间片非常短,我们可以认为许多事件是同时发生的。
即使应用程序只有一个线程,抢先式多任务处理的进程也在进行,因为系统上运行了许多其他过程,每个过程都需要一定的时间片来完成其线程。当屏幕上有许多窗口时,每个窗口都代表不同的过程,可以单击它们中的任一个,让它显示响应。这种响应不是即时的,在相关进程中下一个负责处理该窗口的用户输入的线程得到一个时间片时,这种响应才会发生。如果系统非常忙,就需等待,但这种等待的时间非常短暂,用户不会察觉到。
15.3 线程的处理
线程是使用Thread类来处理的,该类在System.Threading命名空间中。一个Thread实例表示一个线程,即执行序列。通过简单实例化一个Thread对象,就可以创建另一个线程。
启动线程
要使下面的代码段更具体,假定编写一个图形图像编辑器,用户请求修改图像的颜色深度。因为对于一个大的图像,这个操作需要一定的时间才能完成。此时要创建一个单独的线程来处理这个过程,所以在颜色的深度变化时,用户可以不中断用户界面。首先实例化一个Thread 对象:
// entryPoint has been declared previously as a delegate
// of type ThreadStart
Thread depthChangeThread = new Thread(entryPoint);
这段代码指定变量名depthChangeThread。
注意:
在一个应用程序中创建另一个线程,执行一些任务,通常称为工作线程(worker thread)。
上面的代码说明,Thread构造函数需要一个参数,用于指定线程的入口——即线程开始执行的方法。因为我们传送的是方法的详细信息,所以需要使用委托。实际上,委托已经在System. Threading类中定义好了。它称为ThreadStart,其签名如下所示:
public delegate void ThreadStart();
传送给构造函数的参数必须是这种类型的委托。
但完成后,新线程实际上并没有执行任务,它只是在等待执行。我们调用Thread.Start()方法来启动线程。
假定有一个方法ChangeColorDepth():
void ChangeColorDepth()
{
// processing to change color depth of image
}
执行下述代码:
Thread depthChangeThread = new Thread();
depthChangeThread.Name = "Depth Change Thread";
ThreadStart entryPoint = new ThreadStart(ChangeColorDepth);
depthChangeThread.Start();
完成后,两个线程就会同时运行。
图 15-1
在这段代码中,还使用Thread.Name属性给线程赋予一个友好的名称,如图15-1所示,这不是必要的,但非常有效。
注意,线程的入口(在本例中是ChangeColorDepth())不带任何参数,所以必须用其他方式给方法传递它需要的信息。最显而易见的方式就是使用该方法所属的类的成员字段。而且,该方法没有返回值(如果有返回值,则应返回到什么地方?只要这个方法返回,运行它的线程就会终止,所以不能接收任何返回值。我们几乎不能把它返回给调用该线程的线程,因为该线程大概在忙着做其他事)。
启动了一个线程后,还可以挂起、恢复或中止它。挂起一个线程就是让它进入睡眠状态,此时,线程仅是停止运行某段时间,不占用任何处理器时间,以后还可以恢复,从被挂起的那个状态重新运行。如果线程被中止,就是停止运行。Windows会永久地删除该线程的所有数据,所以该线程不能重新启动。
继续上面的图像编辑器例子,假定由于某些原因,用户界面线程显示一个对话框,允许用户选择临时挂起会话进程(用户通常不会这么做,但这仅是一个示例,在更真实的示例中,用户可能是暂停声音文件或视频文件的播放)。在主线程中编写如下响应:
depthChangeThread.Suspend();
如果用户以后要求恢复该线程,可以使用下面的方法:
depthChangeThread.Resume();
最后,如果用户(更真实)决定不进行这样的会话,单击取消按钮,可以使用下面的方法:
depthChangeThread.Abort();
注意Suspend()和 Abort()方法不必立即起作用。对于Suspend()方法,.NET允许要挂起的线程再执行几个指令,目的是为了到达.NET认为线程可以安全挂起的状态。这么做,从技术上讲,是为了确保垃圾收集器执行正确的操作,具体内容见MSDN文档说明。在中止线程时,Abort()方法会在受影响的线程中产生一个ThreadAbortException,ThreadAbortException是一个特殊的异常类,以前我们没有遇到过。以这种方式中止线程,如果线程当前执行try块中的代码,则在线程真正中止前,将执行相应的finally块。这就可以保证清理资源,并有机会确保线程正在处理的数据(例如,在线程中止后仍保留的类实例的字段)处于有效的状态。
注意:
在开发.NET以前,不推荐使用这种方式中止线程,但极端情况除外,因为受影响的线程会立即中止,它正在处理的数据将处于无效状态,线程所使用的资源仍被占用。.NET使用的异常机制可以使线程的中止更加安全。
这种异常机制可以使线程的中止比较安全,但中止线程要用一定的时间,因为从理论上讲,finally块中的代码执行多长时间是没有限制的。因此,在中止线程后需要等待一段时间,线程被真正中止后,才能继续执行其他操作。如果后续的处理依赖于另一个已经中止的线程,可以调用Join()方法,等待线程中止:
depthChangeThread.Abort();
depthChangeThread.Join();
Join() 的其他重载方法可以指定等待的时间期限。如果过了等待的时间期限,程序会继续执行。如果没有指定时间期限,线程就要等待需要等待的时间。
上面的代码段还显示了在一个线程上执行操作的另一个线程(至少在Join()中,是等待另一个线程)。但是,如果主线程要在它自己的线程上执行某些操作,该怎么办?此时需要一个线程对象的引用来表示它自己的线程。使用Thread类的静态属性CurrentThread,就可以获得这样一个引用:
Thread myOwnThread = Thread.CurrentThread;
线程实际上是一个不太好处理的类,因为即使在没有实例化其他线程以前,也总是会有一个线程:目前正在执行的线程。因此处理这个类与其他类有两个区别:
● 可以实例化一个线程对象,它表示一个正在运行的线程,其实例成员应用于正在运行的线程上。
● 可以调用任意个静态方法。这些方法一般会应用到实际调用它们的线程上。
可以调用的一个静态方法是Sleep(),它使正在运行的线程进入睡眠状态,过一段时间之后该线程会继续运行。
15.4 ThreadPlayaround示例
下面用一个简单的示例ThreadPlayaround来说明如何使用线程。这个示例的目的是介绍如何处理线程,而不是说明实际编程问题。
示例ThreadPlayaround的核心是方法DisplayNumbers(),它累加一个数字,并显示每次累加的结果。DisplayNumbers()还会显示它运行的线程名称和文化背景:
static void DisplayNumbers()
{
Thread thisThread = Thread.CurrentThread;
string name = thisThread.Name;
Console.WriteLine("Starting thread: " + name);
Console.WriteLine(name + ": Current Culture = " +
thisThread.CurrentCulture);
for (int i=1 ; i<= 8*interval ; i++)
{
if (i%interval == 0)
Console.WriteLine(name + ": count has reached " + i);
}
}
累加的数字取决于interval字段,它的值是用户输入的。如果用户输入100,就累加到800,显示数字100, 200, 300, 400, 500, 600, 700和800,如果用户输入1000,就累加到8000,显示数字1000, 2000, 3000, 4000, 5000, 6000, 7000和 8000,依次类推。这似乎是一个没有意义的方法,但它的目的是让处理器停止一段时间,以便查看处理器是如何处理这个任务的。
ThreadPlayaround示例启动了第二个工作线程,运行DisplayNumbers(),但启动这个工作线程后,主线程就开始执行同一个方法,此时我们应看到有两个累加过程同时发生。
ThreadPlayaround示例的Main()方法及其包含的类如下所示:
class EntryPoint
{
static int interval;
static void Main()
{
Console.Write("Interval to display results at?> ");
interval = int.Parse(Console.ReadLine());
Thread thisThread = Thread.CurrentThread;
thisThread.Name = "Main Thread";
ThreadStart workerStart = new ThreadStart(StartMethod);
Thread workerThread = new Thread(workerStart);
workerThread.Name = "Worker";
workerThread.Start();
DisplayNumbers();
Console.WriteLine("Main Thread Finished");
Console.ReadLine();
}
}
该代码段从类的声明开始,interval是这个类的一个静态字段。在Main()方法中,首先要求用户输入interval的值。然后获取表示主线程的线程对象引用,这样,就可以给线程指定名称,并可以在结果中看到具体的执行情况。
接着,创建工作线程,设置它的名称,启动它,给它传送一个委托,指定它必须从方法WorkerStart开始执行,最后调用DisplayNumbers()方法,开始累加。工作线程的入口是:
static void StartMethod()
{
DisplayNumbers();
Console.WriteLine("Worker Thread Finished");
}
注意所有这些方法都是类EntryPoint的静态方法。两个累加过程是完全独立的,因为DisplayNumbers()方法中用于累加数字的变量i 是一个局部变量。局部变量只能在定义它们的方法中使用,也只有在执行该方法的线程中是可见的。如果另一个线程开始执行这个方法,该线程就会获得该局部变量的副本。运行这段代码,给interval选择一个相对小的值100,得到如下结果:
ThreadPlayaround
Interval to display results at?> 100
Starting thread: Main Thread
Main Thread: Current Culture = en-US
Main Thread: count has reached 100
Main Thread: count has reached 200
Main Thread: count has reached 300
Main Thread: count has reached 400
Main Thread: count has reached 500
Main Thread: count has reached 600
Main Thread: count has reached 700
Main Thread: count has reached 800
Main Thread Finished
Starting thread: Worker
Worker: Current Culture = en-US
Worker: count has reached 100
Worker: count has reached 200
Worker: count has reached 300
Worker: count has reached 400
Worker: count has reached 500
Worker: count has reached 600
Worker: count has reached 700
Worker: count has reached 800
Worker Thread Finished
对于并行的线程而言,两个线程的执行都非常成功。主线程启动后,累加到800之后完成执行,然后启动工作线程,执行累加过程。
此处的问题是启动线程是一个主进程,在实例化一个新线程后,主线程会遇到下面的代码:
workerThread.Start();
它调用Thread.Start(),告诉Windows新线程已经准备启动,然后即时返回。在累加到800时,Windows就启动新线程,这意味着给该线程分配各种资源,执行各种安全检查。到新线程启动时,主线程已经完成了任务。
解决这个问题的方式是选择一个比较大的 interval,这样,两个线程在DisplayNumbers()方法中花费的时间会比较长,这次给interval输入1000000,得到如下所示的结果:
ThreadPlayaround
Interval to display results at?> 1000000
Starting thread: Main Thread
Main Thread: Current Culture = en-US
Main Thread: count has reached 1000000
Starting thread: Worker
Worker: Current Culture = en-US
Main Thread: count has reached 2000000
Worker: count has reached 1000000
Main Thread: count has reached 3000000
Worker: count has reached 2000000
Main Thread: count has reached 4000000
Worker: count has reached 3000000
Main Thread: count has reached 5000000
Main Thread: count has reached 6000000
Worker: count has reached 4000000
Main Thread: count has reached 7000000
Worker: count has reached 5000000
Main Thread: count has reached 8000000
Main Thread Finished
Worker: count has reached 6000000
Worker: count has reached 7000000
Worker: count has reached 8000000
Worker Thread Finished
现在就可以看出,这两个线程实际上是并行工作的。主线程启动,累加到100万,当主线程计算下一个100万时,工作线程启动,从那时起,两个线程以相同的速度累加,直到完成任务为止。
除非运行一个多处理器计算机,否则在CPU密集的任务中使用两个线程不能节省多少时间,理解这一点是很重要的。在单处理器计算机上,让两个线程都累加到800万所花的时间与让一个线程累加到1600万是相同的,甚至使用两个线程所用的时间会略长,因为要处理另一个线程,操作系统必须用一定的时间切换线程,但这种区别可以忽略不计。使用多个线程的优点有两个。首先,可以作出响应,因为一个线程在处理用户输入时,另一个线程在后台完成其他工作;第二,如果一个或多个线程所处理的工作不占用CPU时间(例如,等待从Internet中获取数据),就可以节省时间,因为其他线程可以在未激活的线程处于等待状态时执行它们的任务。
|
|
|
--
|
|
[Original]
[Print]
[Top]
|
|
[Original]
[Print]
[Top]
|
15.5 线程的优先级
如果在应用程序中有多个线程在运行,但一些线程比另一些线程重要,该怎么办?在这种情况下,可以在一个进程中为不同的线程指定不同的优先级。一般情况下,如果有优先级较高的线程在工作,就不会给优先级较低的线程分配任何时间片,其优点是可以保证给接收用户输入的线程指定较高的优先级。在大多数的时间内,这个线程什么也不做,而其他线程则执行它们的任务。但是,如果用户输入了信息,这个线程就立即获得比应用程序中其他线程更高的优先级,在短时间内处理用户输入事件。
高优先级的线程可以完全阻止低优先级的线程执行,因此在改变线程的优先级时要特别小心。线程的优先级可以定义为ThreadPriority枚举的值,即Highest、AboveNormal、Normal、BelowNormal和 Lowest。
注意,每个进程都有一个基本优先级,这些值与进程的优先级是有关系的。给线程指定较高的优先级,可以确保它在该进程中比其他线程优先执行,但系统上可能还运行着其他进程,它们的线程有更高的优先级。因此Windows给自己的操作系统线程指定高优先级。
在ThreadPlayaround示例中,对Main()方法做如下修改,就可以看出修改线程的优先级的效果:
ThreadStart workerStart = new ThreadStart(StartMethod);
Thread workerThread = new Thread(workerStart);
workerThread.Name = "Worker";
workerThread.Priority = ThreadPriority.AboveNormal;
workerThread.Start();
其中,工作线程的优先级比主线程高,运行结果如下所示:
ThreadPlayaroundWithPriorities
Interval to display results at?> 1000000
Starting thread: Main Thread
Main Thread: Current Culture = en-US
Starting thread: Worker
Worker: Current Culture = en-US
Main Thread: count has reached 1000000
Worker: count has reached 1000000
Worker: count has reached 2000000
Worker: count has reached 3000000
Worker: count has reached 4000000
Worker: count has reached 5000000
Worker: count has reached 6000000
Worker: count has reached 7000000
Worker: count has reached 8000000
Worker Thread Finished
Main Thread: count has reached 2000000
Main Thread: count has reached 3000000
Main Thread: count has reached 4000000
Main Thread: count has reached 5000000
Main Thread: count has reached 6000000
Main Thread: count has reached 7000000
Main Thread: count has reached 8000000
Main Thread Finished
这说明,当工作线程的优先级为AboveNormal时,一旦工作线程被启动,主线程就不再运行。
15.6 同步
使用线程的一个重要方面是同步访问多个线程访问的任何变量。所谓同步,是指在某一时刻只有一个线程可以访问变量。如果不能确保对变量的访问是同步的,就会产生错误。本节将简要介绍同步的一些主要内容。
15.6.1 同步的含义
同步问题的产生,是由于在C#源代码中,大多数情况下看起来是一条语句,但在最后编译好的汇编语言机器码中会被翻译为许多条语句。看看下面这个语句:
message += ", there"; // message is a string that contains "Hello"
这条语句在C#语法上是一条语句,但在执行代码时,实际上它涉及到许多操作。需要分配内存,以存储更长的新字符串,需要设置变量message,使之指向新的内存,需要复制实际文本等。
显然,这里选择了一种复杂字符串,但即使在基本数字类型上执行算术操作,后台进行的操作也比从C#代码中看到的要多。而且,许多操作不能直接在存储于内存空间中的变量上进行,它们的值必须单独复制到处理器的特定位置上,即寄存器。
只要一个C#语句翻译为多个本机代码命令,线程的时间片就有可能在执行该语句的进程中终止,如果是这样,同一个进程中的另一个线程就会获得一个时间片,如果涉及到这条语句的变量访问(在上面的示例中,是message)不是同步的,那么另一个线程可能读写同一个变量。在上面的示例中,另一个线程是访问message的新值还是旧值?
问题可能比这更严重。在上面示例中使用的语句是相对简单的,但在执行比较复杂的语句时,某个变量可能在执行该语句的某个较短的时间内有一个未定义的值。如果另一个线程此时要读取这个值,将只会读取一个垃圾值。更严重的是,如果两个线程同时给同一个变量写入数据,该变量肯定会包含不正确的值。
同步问题不会影响ThreadPlayAround示例,因为在该示例中,两个线程主要使用局部变量。这两个线程都可以访问的惟一变量是interval字段,但在启动其他线程前,这个字段在主线程中被初始化,以后也仅在两个线程中读取它的值,因此不会出问题。同步问题只发生在下述场合中:至少有一个线程要写入一个变量,而与此同时,其他线程正在读取或写入同一个变量。
C#为同步访问变量提供了一个非常简单的方式,即使用C#语言的关键字lock,其用法如下所示:
lock (x)
{
DoSomething();
}
lock语句把变量放在圆括号中,以包装对象,称为独占锁或排它锁。当执行带有lock关键字的复合语句时,独占锁会保留下来。当变量被包装在独占锁中时,其他线程就不能访问该变量。如果在上面的代码中使用独占锁,在执行复合语句时,这个线程就会失去其时间片。如果下一个获得时间片的线程试图访问变量x,就会被拒绝。Windows会让其他线程处于睡眠状态,直到解除了独占锁为止。
独占锁是控制变量访问的许多机制中最简单的。这里不能深入讨论其他机制,但它们都可以通过.NET基类System.Threading.Monitor来控制。实际上,C#的lock语句只是一个C#语法包装器,它封装了这个类的两个方法调用。
一般情况下,当一个线程写入一个变量,同时有其他线程读取或写入这个变量时,就应同步变量。这里不详细介绍线程同步的内容,但这是一个很大的主题,下面讨论有关同步的两个潜在问题。
15.6.2 同步问题
同步线程在多线程应用程序中非常重要。但是,这是一个需要详细讨论的内容,因为很容易出现微妙且难以察觉的问题,特别是死锁dead lock和竞态条件race conditions。
(1) 不要滥用同步
线程同步非常重要,但只在需要时使用也是非常重要的。因为这会降低性能。原因有两个:首先,在对象上放置和解开锁会带来某些系统开销,但这些系统开销都非常小。第二个原因更为重要,线程同步使用得越多,等待释放对象的线程就越多。如果一个线程在对象上放置了一个锁,需要访问该对象的其他线程就只能暂停执行,直到该锁被解开,才能继续执行。因此,在lick块内部编写的代码越少越好,以免出现线程同步错误。lock语句在某种意义上就是临时禁用应用程序的多线程功能,也就临时删除了多线程的各种优势。
另一方面,使用过多的同步线程的危险性(性能和响应降低)并没有在需要时不使用同步线程那么高(难以跟踪的运行时错误)。
(2) 死锁
死锁是一个错误,在两个线程都需要访问被互锁的资源时发生。假定一个线程运行下述代码,其中a和b是两个线程都可以访问的对象引用:
lock (a)
{
// do something
lock (b)
{
// do something
}
}
同时,另一个线程运行下述代码:
lock (b)
{
// do something
lock (a)
{
// do something
}
}
根据线程遇到不同语句的时间,可能会出现下述情况:第一个线程在a上有一个锁,同时第二个线程在b上有一个锁。不久,线程A遇到lock(b)语句,立即进人睡眠状态,等待b上的锁被解开。之后,第二个线程遇到lock(a)语句,也立即进人睡眠状态,等待Windows在a上的锁被解开时唤醒它。但a上的锁永远不会解开,因为第一个线程拥有这个锁,目前正处于睡眠状态,在b上的锁被解开前是不会醒的,而在第二个线程被叫醒之前,b上的锁不会解开,结果就是一个死锁。两个线程都不会做任何事,而仅是等待另一个线程解开它们的锁。这类问题会使整个应用程序挂起,不能执行任何操作,除非使用“任务管理器”中断整个进程。
注意:
在这种情况下,另一个线程不可能解开锁:独占锁只能由定义它的线程解开。
让两个线程以相同的顺序在对象上声明加锁,就可以避免发生死锁。在上面的示例中,如果第二个线程声明加锁的顺序与第一个线程相同,a先b后,则无论哪个线程先在a上加锁,都会先完成它的任务后,才启动另一个线程。这样,就不会发生死锁了。
在编码中很容易避免死锁,在上面的代码中,发生死锁是非常明显的,所以用户肯定不会编写这样的代码,但记住不同的锁可以发生在不同的方法调用中。在这个示例中,第一个线程实际执行下述代码:
lock (a)
{
// do bits of processing
CallSomeMethod()
}
CallSomeMethod()可以调用其他方法,其中有一个lock(b)语句,此时编写一段代码,则是否会发生死锁就不那么明显了。
(3) 竞态条件
竞态条件比死锁更微妙。它很少中断进程的执行,但可能导致数据损坏。很难给竞态下一个准确的定义,但当几个线程试图访问同一个数据,但没有充分考虑其他线程的执行情况时,就会发生竞态。最好用一个示例来理解竞态条件。
假定有一个对象数组,其中每个元素都需要处理,现在使用许多线程来进行这种处理。假定有一个对象ArrayController,它包含了对象数组和一个int,该int表示有多少对象已经处理完毕,下一次应处理哪一个对象。ArrayController执行下述方法:
int GetObject(int index)
{
// returns the object at the given index.
}
和一个读写属性:
int ObjectsProcessed
{
// indicates how many of the objects have been processed.
}
帮助处理对象的每个线程都执行下述代码:
lock(ArrayController)
{
int nextIndex = ArrayController.ObjectsProcessed;
Console.WriteLine("object to be processed next is " + index);
++ArrayController.ObjectsProcessed;
object next = ArrayController.GetObject();
}
ProcessObject(next);
这段代码可以工作,但假定为了避免资源被长期搁置不用,在显示用户信息时不在ArrayController上放置锁。因此,把上述代码重写为:
lock(ArrayController)
{
int nextIndex = ArrayController.ObjectsProcessed;
}
Console.WriteLine("object to be processed next is " + index);
lock(ArrayController)
{
++ArrayController.ObjectsProcessed;
object next = ArrayController.GetObject();
}
ProcessObject(next);
现在可能有一个问题。在一个线程获得数组中的第11个对象,并显示信息,说明它在处理该对象时,会发生什么?与此同时,第二个线程也开始执行相同的代码,调用ObjectsProcessed,并确定要处理的下一个对象就是数组中的第11个对象——因为第一个线程仍然还没有更新ArrayController.ObjectsProcessed。在第二个线程告诉控制台,它正在处理第11个对象时,第一个线程在ArrayController上放置了另一个锁,并在这个锁内部递增了ObjectsProcessed。但太迟了,这两个线程在处理同一个对象,此时的情形就称为竞态条件。
对于死锁和竞态条件,出现这两种错误的条件常常不明显,如果有这样的条件,也很难识别错误。一般情况下,这需要一定的经验。但是,在编写多线程应用程序时,如果需要同步,就必须考虑代码的所有部分,检查是否有可能发生死锁或竞态条件。记住,不可能预见不同线程遇到不同语句的确切时间。
15.7 小结
本文介绍了如何通过System.Threading命名空间编写多线程应用程序。在应用程序中使用多线程要仔细规划。太多的线程会导致资源问题,线程不足又会使应用程序执行缓慢,执行效果也不好。
.NET Framework中的System.Threading命名空间允许处理线程,但.NET Framework并没有完成多线程中所有困难的任务。我们必须考虑线程的优先级和同步问题。本文讨论了这些问题,介绍了如何在C#应用程序中为它们编码。还论述了与死锁和竞态条件相关的问题。
如果要在C#应用程序中使用多线程功能,就必须仔细规划。
本文摘至清华大学出版社出版的Wrox红皮书《C#高级编程(第3版)》,转载必须标明出处
|
|
|
--
|
|
[Original]
[Print]
[Top]
|
|
[Original]
[Print]
[Top]
|
C#中的内存管理和指针(一)
本文摘至清华大学出版社出版的Wrox红皮书《C#高级编程(第3版)》,转载必须标明出处
本文介绍内存管理和内存访问的各个方面。尽管运行库负责为程序员处理大部分内存管理工作,但程序员仍必须理解内存管理的工作原理,知道如何处理未托管的资源。
如果很好地理解了内存管理和C#提供的指针功能,也就能很好地集成C#代码和原来的代码,并能在非常注重性能的系统中高效地处理内存。
本文的主要内容如下:
● 运行库如何在堆栈和堆上分配空间
● 垃圾收集的工作原理
● 如何使用析构函数和System.IDisposable接口来确保未托管的资源的正确释放
● C#中使用指针的语法
● 如何使用指针实现高性能且基于堆栈的数组
7.1 后台内存管理
C#编程的一个优点是程序员不需要担心具体的内存管理,尤其是垃圾收集器会处理所有的内存清理工作。用户可以得到像C++语言那样的效率,而不需要考虑像在C++中那样内存管理工作的复杂性。虽然不必手工管理内存,但如果要编写高效的代码,就仍需理解后台发生的事情。本节要介绍给变量分配内存时计算机内存中发生的情况。
注意:
本节的许多内容是没有经过事实证明的。您应把这一节看作是一般规则的简化向导,而不是实现的确切说明。
7.1.1 值数据类型
Windows使用一个系统:虚拟寻址系统,该系统把程序可用的内存地址映射到硬件内存中的实际地址上,这些任务完全由Windows在后台管理,其实际结果是32位处理器上的每个进程都可以使用4GB的内存—— 无论计算机上有多少硬盘空间。(在64位处理器上,这个数字会更大)。这个4GB内存实际上包含了程序的任何一部分—— 包括可执行代码、代码加载的所有DLL,以及程序运行时使用的所有变量的内容。这个4GB内存称为虚拟地址空间,或虚拟内存,为了方便起见,我们继续把它当作一般内存来使用。
4GB中的每个存储单元都是从0开始往上排序的。要把一个值存储在内存的某个空间中,就需要提供表示该存储单元的数字。在任何高级语言中,例如C#、VB、C++和Java,编译器负责把人们可以理解的名称转换为处理器可以理解的内存地址。
在进程的虚拟内存中,有一个区域称为堆栈。堆栈存储不是对象成员的值数据类型。另外,在调用一个方法时,也使用堆栈复制传递给方法的所有参数。为了理解堆栈的工作原理,需要注意在C#中变量的作用域。如果变量a在变量b之前进入作用域,b就会先出作用域。下面的代码:
{
int a;
// do something
{
int b;
// do something else
}
}
首先声明a。在内部的代码块中声明了b。然后内部的代码块终止,b就出作用域,最后a出作用域。所以b的生存期会完全包含在a的生存期中。在解除变量时,其顺序总是与给它们分配内存的顺序相反,这就是堆栈的工作方式。
我们不知道堆栈在地址空间的什么地方,这些信息在进行C#开发是不需要知道的。堆栈指针(操作系统维护的一个变量) 包含堆栈中下一个自由空间的地址。程序第一次运行时,堆栈指针指向堆栈保留的内存块末尾。堆栈实际上是向下填充的,即从高内存地址向低内存地址填充。当数据入栈后,堆栈指针就会随之调整,以始终指向下一个自由空间。这种情况如图7-1所示。在该图中,显示了堆栈指针800000(16进制的0xC3500),下一个自由空间是地址79999。
图 7-1
在下面的代码中,我们已告诉编译器需要一些存储单元以存储一个整数和一个双精度浮点数,这些存储单元会分别分配给nRacingCars和engineSize,声明每个变量的代码表示开始请求访问这个变量,闭合花括号表示不再请求其他变量。
{
int nRacingCars = 10;
double engineSize = 3000.0;
// do calculations;
}
假定使用如图7-1所示的堆栈。变量nRacingCars放在内存中,其值是10,这个值放在存储单元799996~799999上,这4个字节就在堆栈指针所指空间的下面。有4个字节是因为存储int要使用4个字节。为了容纳该int,应从堆栈指针中减去4,所以它现在指向位置799996,即下一个自由空间之后(79995)。
当engineSize出作用域时,计算机就知道不再需要这个变量了。因为变量的生存期总是嵌套的,可以保证,当engineSize在作用域中时,无论发生什么情况,堆栈指针总是会指向存储engineSize的空间。为了从内存中删除这个变量,应给堆栈指针递增8,现在指向engineSize使用过的空间。此处就是放置闭合花括号的地方,当nRacingCars也出作用域时,堆栈指针就再次递增4,此时如果内存中又放入另一个变量,从799999开始的存储单元就会被覆盖,这些空间以前是存储nRacingCars的。
如果编译器遇到像int i、j这样的代码,则这两个变量进入作用域的顺序就是不确定的:两个变量是同时声明的,也是同时出作用域的。此时,变量以什么顺序从内存中删除就不重要了。编译器在内部会确保先放在内存中的那个变量后删除,这样就能保证该规则不会与变量的生存期冲突。
7.1.2 引用数据类型
堆栈有非常高的性能,但对于所有的变量来说还是不太灵活。变量的生存期必须嵌套,在许多情况下,这种要求都过于苛刻。通常我们希望使用一个方法分配内存,来存储一些数据,并在方法退出后的很长一段时间内数据仍是可以使用的。只要是用new运算符来请求存储空间,就存在这种可能性——例如所有的引用类型。此时就要使用托管堆。
如果以前编写过需要管理低级内存的C++代码,就会很熟悉堆(heap)。托管堆和C++使用的堆不同,它在垃圾收集器的控制下工作,与传统的堆相比有很显著的性能优势。
托管堆(或简称为堆)是进程的可用4GB中的另一个内存区域。要了解堆的工作原理和如何为引用数据类型分配内存,看看下面的代码:
void DoWork()
{
Customer arabel;
arabel = new Customer();
Customer mrJones = new Nevermore60Customer();
}
在这段代码中,假定存在两个类Customer 和 Nevermore60Customer。这些类实际上取自于附录A中的Mortimer Phones例子(在www.wrox.com上)。
首先,声明一个Customer引用,该引用名为arabel,在堆栈上给这个引用分配存储空间,但这仅是一个引用,而不是实际的Customer对象。arabel引用占用4个字节的空间,包含了存储Customer对象的地址(需要4个字节把0到4GB之间的地址存储为一个整数值)。
然后看下一行代码:
arabel = new Customer();
这行代码完成了以下操作:首先,分配堆上的内存,以存储Customer实例(一个真正的实例,不只是一个地址)。然后把变量arabel的值设置为分配给新Customer对象的内存地址(它还调用合适的Customer()构造函数初始化类实例中的字段,但我们不必担心这部分)。
Customer实例没有放在堆栈中,而是放在内存的堆中。在这个例子中,现在还不知道一个Customer对象占用多少字节,但为了讨论方便,假定是32B。这32B包含了Customer实例字段,和.NET用于识别和管理其类实例的一些信息。
为了在堆上找到一个存储新Customer对象的存储位置,.NET运行环境在堆中搜索,选取第一个未使用的、32B的连续块。为了讨论方便,假定其地址是200000,arabel引用占用堆栈中的799996~799999位置。这表示在实例化arabel对象前,内存的内容应如图7-2所示。
图 7-2
给Customer对象分配空间后,内存内容应如图7-3所示。注意,与堆栈不同,堆上的内存是向上分配的,所以自由空间在已用空间的上面。
图 7-3
下一行代码声明了一个Customer引用,并实例化一个Customer对象。在这个例子中,需要在堆栈上为mrJones引用分配空间,同时,也需要在堆上为它分配空间:
Customer mrJones = new Nevermore60Customer();
该行把堆栈上的4B分配给mrJones引用,它存储在799992~799995位置上,而mrJones实例在堆上从200032开始向上分配空间。
从这个例子可以看出,建立引用变量的过程要比建立值变量的过程更复杂,且不能避免性能的降低。实际上,我们对这个过程进行了过份的简化,因为.NET运行库需要保存堆的状态信息,在堆中添加新数据时,这些信息也需要更新。尽管有这些性能损失,但仍有一种机制,在给变量分配内存时,不会受到堆栈的限制。把一个引用变量的值赋予另一个相同类型的变量,就有两个引用内存中同一对象的变量了。当一个引用变量出作用域时,它会从堆栈中删除,如上一节所述,但引用对象的数据仍保留在堆中,一直到程序停止,或垃圾收集器删除它为止,而只有在该数据不再被任何变量引用时,才会被删除。
7.1.3 垃圾收集
由上面的讨论和图可以看出,托管堆的工作方式非常类似于堆栈,在某种程度上,连续的对象会在内存中一个挨一个地放置,这样就很容易使用指向下一个空闲存储单元的堆指针,来确定下一个对象的位置。在堆上添加更多的对象时,也容易调整。但这比较复杂,因为基于堆的对象的生存期与引用它们的基于堆栈的对象的作用域不匹配。
在垃圾收集器运行时,会在堆中删除不再引用的所有对象。在完成删除动作后,堆会立即把对象分散开来,与已经释放的内存混合在一起,如图7-4所示。
图 7-4
如果托管的堆也是这样,在其上给新对象分配内存就成为一个很难处理的过程,运行库必须搜索整个堆,才能找到足够大的内存块来存储每个新对象。但是,垃圾收集器不会让堆处于这种状态。只要它释放了能释放的所有对象,就会压缩其他对象,把它们都移动回堆的端部,再次形成一个连续的块。因此,对于在什么地方存储新对象,堆可以继续像堆栈那样工作。当然,在移动对象时,这些对象的所有引用都需要用正确的新地址来更新,但垃圾收集器也会处理更新问题。
垃圾收集器的这个压缩操作是托管的堆与旧未托管的堆的区别所在。使用托管的堆,就只需要读取堆指针的值即可,而不是搜索链接地址列表,来查找一个地方来放置新数据。因此,在.NET下实例化对象要快得多。有趣的是,访问它们也比较快,因为对象会压缩到堆上相同的内存区域,这样需要交换的页面较少。Microsoft相信,尽管垃圾收集器需要做一些工作,修改它移动的所有对象引用,致使性能降低,但这些性能会得到弥补。
注意:
一般情况下,垃圾收集器在.NET运行库认为需要时运行。可以通过调用System.GC.Collect(),强迫垃圾收集器在代码的某个地方运行,System.GC是一个表示垃圾收集器的.NET基类, Collect()方法则调用垃圾收集器。但是,这种方式适用的场合很少,例如,代码中有大量的对象刚刚停止引用,就适合调用垃圾收集器。但是,垃圾收集器的逻辑不能保证在一次垃圾收集过程中,所有未引用的对象都从堆中删除。
|
|
|
--
|
|
[Original]
[Print]
[Top]
|
|
[Original]
[Print]
[Top]
|
C#中的内存管理和指针(二)
7.2 释放未托管的资源
垃圾收集器的出现意味着,通常不需要担心不再需要的对象,只要让这些对象的所有引用都超出作用域,并允许垃圾收集器在需要时释放资源即可。但是,垃圾收集器不知道如何释放未托管的资源(例如文件句柄、网络连接和数据库连接)。托管类在封装对未托管资源的直接或间接引用时,需要制定专门的规则,确保未托管的资源在回收类的一个实例时释放。
在定义一个类时,可以使用两种机制来自动释放未托管的资源。这些机制常常放在一起实现,因为每个机制都为问题提供了略为不同的解决方法。这两个机制是:
● 声明一个析构函数,作为类的一个成员
● 在类中实现System.IDisposable接口
下面依次讨论这两个机制,然后介绍如何同时实现它们,以获得最佳的效果。
7.2.1 析构函数
前面介绍了构造函数可以指定必须在创建类的实例时进行的某些操作,在垃圾收集器删除对象时,也可以调用析构函数。由于执行这个操作,所以析构函数初看起来似乎是放置释放未托管资源、执行一般清理操作的代码的最佳地方。但是,事情并不是如此简单。
注意:
在讨论C#中的析构函数时,在底层的.NET结构中,这些函数称为Finalizers。在C#中定义析构函数时,编译器发送给程序集的实际上是Finalize()方法。这不会影响源代码,但如果需要查看程序集的内容,就应知道这个事实。
C++开发人员应很熟悉析构函数的语法,它看起来类似于一个方法,与包含类同名,但前面加上了一个发音符号(~)。它没有返回类型,不带参数,没有访问修饰符。下面是一个 例子:
class MyClass
{
~MyClass()
{
// implementation
}
}
C#编译器在编译析构函数时,会隐式地把析构函数的代码编译为Finalize()方法的对应代码,确保执行父类的Finalize()方法。下面列出了编译器为~MyClass()析构函数生成的IL的对应C#代码:
protected override void Finalize()
{
try
{
// implementation
}
finally
{
base. Finalize();
}
}
如上所示,在~MyClass()析构函数中执行的代码封装在Finalize()方法的一个try块中。对父类Finalize()方法的调用放在finally块中,确保该调用的执行。
有经验的C++开发人员扩展了析构函数的用法,有时不仅用于清理资源,还提供调试信息或执行其他任务。C#析构函数的使用要比在C++中少得多,与C++析构函数相比,C#析构函数的问题是它们的不确定性。在删除C++对象时,其析构函数会立即运行。但由于垃圾收集器的工作方式,无法确定C#对象的析构函数何时执行。所以,不能在析构函数中放置需要在某一时刻运行的代码,也不应使用能以任意顺序对不同类实例调用的析构函数。如果对象占用了宝贵而重要的资源,应尽可能快地释放这些资源,此时就不能等待垃圾收集器来释放了。
另一个问题是析构函数的执行会延迟对象最终从内存中删除的时间。没有析构函数的对象会在垃圾收集器的一次处理中从内存中删除,但有析构函数的对象需要两次处理才能删除:第一次调用析构函数时,没有删除对象,第二次调用才真正删除对象。另外,运行库使用一个线程来执行所有对象的Finalize()方法。如果频繁使用析构函数,而且使用它们执行长时间的清理任务,对性能的影响就会非常显著。
7.2.2 IDisposable接口
一个推荐替代析构函数的方式是使用System.IDisposable接口。IDisposable接口定义了一个模式(具有语言级的支持),为释放未托管的资源提供了确定的机制,并避免产生析构函数固有的与垃圾函数器相关的问题。IDisposable接口声明了一个方法Dispose(),它不带参数,返回void,Myclass的方法Dispose()的执行代码如下:
class Myclass : IDisposable
{
public void Dispose()
{
// implementation
}
}
Dispose()的执行代码显式释放由对象直接使用的所有未托管资源,并在所有实现IDisposable接口的封装对象上调用Dispose()。这样,Dispose()方法在释放未托管资源时提供了精确的控制。
假定有一个类ResourceGobbler,它使用某些外部资源,且执行IDisposable接口。如果要实例化这个类的实例,使用它,然后释放它,就可以使用下面的代码:
ResourceGobbler theInstance = new ResourceGobbler();
// do your processing
theInstance.Dispose();
如果在处理过程中出现异常,这段代码就没有释放theInstance使用的资源,所以应使用try块,编写下面的代码:
ResourceGobbler theInstance = null;
try
{
theInstance = new ResourceGobbler();
// do your processing
}
finally
{
if (theInstance != null) theInstance.Dispose();
}
即使在处理过程中出现了异常,这个版本也可以确保总是在theInstance上调用Dispose(),总是释放由theInstance使用的资源。但是,如果总是要重复这样的结构,代码就很容易被混淆。C#提供了一种语法,可以确保在引用超出作用域时,在对象上自动调用Dispose()(但不是Close())。该语法使用了using关键字来完成这一工作—— 但目前,在完全不同的环境下,它与命名空间没有关系。下面的代码生成与try块相对应的IL代码:
using (ResourceGobbler theInstance = new ResourceGobbler())
{
// do your processing
}
using语句的后面是一对圆括号,其中是引用变量的声明和实例化,该语句使变量放在随附的复合语句中。另外,在变量超出作用域时,即使出现异常,也会自动调用其Dispose()方法。如果已经使用try块来捕获其他异常,就会比较清晰,如果避免使用using语句,仅在已有的try块的finally子句中调用Dispose(),还可以避免进行额外的缩进。
注意:
对于某些类来说,使用Close()要比Dispose()更富有逻辑性,例如,在处理文件或数据库连接时,就是这样。在这些情况下,常常实现IDisposable接口,再执行一个独立的Close()方法,来调用Dispose()。这种方法在类的使用上比较清晰,还支持C#提供的using语句。
7.2.3 实现IDisposable接口和析构函数
前面的章节讨论了类所使用的释放未托管资源的两种方式:
● 利用运行库强制执行的析构函数,但析构函数的执行是不确定的,而且,由于垃圾收集器的工作方式,它会给运行库增加不可接受的系统开销。
● IDisposable接口提供了一种机制,允许类的用户控制释放资源的时间,但需要确保执行Dispose()。
一般情况下,最好的方法是执行这两种机制,获得这两种机制的优点,克服其缺点。假定大多数程序员都能正确调用Dispose(),实现IDisposable接口,同时把析构函数作为一种安全的机制,以防没有调用Dispose()。下面是一个双重实现的例子:
public class ResourceHolder : IDisposable
{
private bool isDispose = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!isDisposed)
{
if (disposing)
{
// Cleanup managed objects by calling their Dispose() methods.
}
// Cleanup unmanaged objects
}
isDisposed=true;
}
~ResourceHolder()
{
Dispose (false);
}
}
可以看出,Dispose()有第二个protected重载方法,它带一个bool参数,这是真正完成清理工作的方法。Dispose(bool)由析构函数和IDisposable.Dispose()调用。这个方式的重点是确保所有的清理代码都放在一个地方。
传递给Dispose(bool)的参数表示Dispose(bool)是由析构函数调用,还是由IDisposable.Dispose()调用——Dispose(bool)不应从代码的其他地方调用,其原因是:
● 如果客户调用IDisposable.Dispose(),该客户就指定应清理所有与该对象相关的资源,包括托管和非托管的资源。
● 如果调用了析构函数,在原则上,所有的资源仍需要清理。但是在这种情况下,析构函数必须由垃圾收集器调用,而且不应访问其他托管的对象,因为我们不再能确定它们的状态了。在这种情况下,最好清理已知的未托管资源,希望引用的托管对象还有析构函数,执行自己的清理过程。
isDispose成员变量表示对象是否已被删除,并允许确保不多次删除成员变量。这个简单的方法不是线程安全的,需要调用者确保在同一时刻只有一个线程调用方法。要求客户进行同步是一个合理的假定,在整个.NET类库中反复使用了这个假定(例如在集合类中)。
最后,IDisposable.Dispose()包含一个对System.GC. SuppressFinalize()方法的调用。SuppressFinalize()方法则告诉垃圾收集器有一个类不再需要调用其析构函数了。因为Dispose()已经完成了所有需要的清理工作,所以析构函数不需要做任何工作。调用SuppressFinalize()就意味着垃圾收集器认为这个对象根本没有析构函数。
|
|
[Original]
[Print]
[Top]
|
|
[Original]
[Print]
[Top]
|
C#中的内存管理和指针(三)
7.3 不安全的代码
如前面的章节所述,C#非常擅长于隐藏基本内存管理,因为它使用了垃圾收集器和引用。但是,有时需要直接访问内存,例如由于性能问题,要在外部(非.NET环境)的DLL中访问一个函数,该函数需要把一个指针当作参数来传递(许多Windows API函数就是这样)。本节将论述C#直接访问内存内容的功能。
7.3.1 指针
下面把指针当作一个新论题来介绍,而实际上,指针并不是新东西,因为在代码中可以自由使用引用,而引用就是一个类型安全的指针。前面已经介绍了表示对象和数组的变量实际上包含存储相应数据(引用)的内存地址。指针只是一个以与引用相同的方式存储数据的变量。其区别是C#的引用语法不允许直接访问引用变量包含的地址。有了引用后,从语法上看,变量就可以存储引用的实际内容。
C#引用主要用于使C#语言易于使用,防止用户无意中执行某些破坏内存中内容的操作,另一方面,使用指针,就可以访问实际内存地址,执行新类型的操作。例如,可以给地址加上4B,这样就可以查看甚至修改存储在新地址中的数据。
下面是使用指针的两个主要原因:
● 向后兼容性。尽管.NET运行库提供了许多工具,但仍可以调用旧的Windows API 函数。 对于某些操作来说,这可能是完成任务的惟一方式。这些API函数都是用C语言编写的,通常要求把指针作为其参数。但在许多情况下,还可以使用DllImport声明,以避免使用指针,例如使用System.IntPtr类。
● 性能。在一些情况下,速度是最重要的,而指针可以提供最优性能。假定用户知道自己在做什么,就可以确保以最高效的方式访问或处理数据。但是,注意在代码的其他区域中,不使用指针,也可以对性能做必要的改进。请使用代码配置文件,查找代码中的瓶颈,代码配置文件随VS.NET一起安装。
但是,这种低级内存访问也是有代价的。使用指针的语法比引用类型更复杂。而且,指针使用起来比较困难,需要非常高的编程技巧和很强的能力,仔细考虑代码所完成的逻辑操作,才能成功地使用指针。如果不仔细,使用指针很容易在程序中引入微妙的、难以查找的错误。例如很容易重写其他变量,导致堆栈溢出,访问某些没有存储变量的内存区域,甚至重写.NET运行库所需要的代码信息,因而使程序崩溃。
另外,如果使用指针就必须为代码获取代码访问安全机制的高级别信任,否则就不能执行。在默认的代码访问安全策略中,只有代码运行在本地机器上,这才是可能的。如果代码必须运行在远程地点,例如Internet,用户就必须给代码授予额外的许可,代码才能工作。除非用户信任您和你的代码,否则他们不会授予这些许可。
尽管有这些问题,但指针在编写高效的代码时是一种非常强大和灵活的工具,这里就介绍指针的使用。
注意:
这里强烈建议不要使用指针,因为如果使用指针,代码不仅难以编写和调试,而且无法通过CLR的内存类型安全检查。
1. 编写不安全的代码
因为使用指针会带来相关的风险,所以C#只允许在特别标记的代码块中使用指针。标记代码所用的关键字是unsafe。下面的代码把一个方法标记为unsafe:
unsafe int GetSomeNumber()
{
// code that can use pointers
}
任何方法都可以标记为unsafe—— 无论该方法是否应用了其他修饰符(例如,静态方法、虚拟方法等)。在这种方法中,unsafe修饰符还会应用到方法的参数上,允许把指针用作参数。还可以把整个类或结构标记为unsafe,表示所有的成员都是不安全的:
unsafe class MyClass
{
// any method in this class can now use pointers
}
同样,可以把成员标记为unsafe:
class MyClass
{
unsafe int *pX; // declaration of a pointer field in a class
}
也可以把方法中的一个代码块标记为unsafe:
void MyMethod()
{
// code that doesn't use pointers
unsafe
{
// unsafe code that uses pointers here
}
// more 'safe' code that doesn't use pointers
}
但要注意,不能把局部变量本身标记为unsafe:
int MyMethod()
{
unsafe int *pX; // WRONG
}
如果要使用不安全的局部变量,就需要在方法或不安全的语句块中声明和使用它。在使用指针前还有一步要完成。C#编译器会拒绝不安全的代码,除非告诉编译器代码包含不安全的代码块。标记所用的关键字是unsafe。因此,要编译包含不安全代码的文件MySource.cs(假定没有其他编译器选项),就要使用下述命令:
csc /unsafe MySource.cs
或者
csc –unsafe MySource.cs
注意:
如果使用Visual Studio .NET,就可以在项目属性中找到编译不安全代码的选项。对于本节中可下载示例的Visual Studio .NET版本,我们已经设置了不安全编译选项。
2. 指针的语法
把代码块标记为unsafe后,就可以使用下面的语法声明指针:
int* pWidth, pHeight;
double* pResult;
byte*[] pFlags;
这段代码声明了4个变量,pWidth和pHeight是整数指针,pResult是double型指针,pFlags是byte型的指针数组。我们常常在指针变量名的前面使用前缀p来表示这些变量是指针。在变量声明中,符号*表示声明一个指针,换言之,就是存储特定类型的变量的地址。
提示:
C++开发人员应注意,这个语法与C#中的语法是不同的。C#语句中 int* pX, pY; 对应于C++ 语句中的 int *pX, *pY;在C#中,*符号与类型相关,而不是与变量名相关。
声明了指针类型的变量后,就可以用与一般变量的方式使用它们,但首先需要学习另外两个运算符:
● & 表示“取地址”,并把一个值数据类型转换为指针,例如int转换为*int。这个运算符称为寻址运算符。
● * 表示“获取地址的内容”,把一个指针转换为值数据类型(例如,*float转换为float)。这个运算符称为“间接寻址运算符”(有时称为“取消引用运算符”)。
从这些定义中可以看出,&和*的作用是相反的。
注意:
符号&和*也表示按位AND(&)和乘法(*)运算符,那么如何以这种方式使用它们?答案是在实际使用时它们是不会混淆的:用户和编译器总是知道在什么情况下这两个符号有什么含义,因为按照新指针的定义,这些符号总是以一元运算符的形式出现—— 它们只作用于一个变量,并出现在代码中变量的前面。另一方面,按位AND和乘法运算符是二元运算符,它们需要两个变量。
下面的代码说明了如何使用这些运算符:
int x = 10;
int* pX, pY;
pX = &x;
pY = pX;
*pY = 20;
首先声明一个整数x,接着声明两个整数指针pX和pY。然后把pX设置为指向x(换言之,把pX的内容设置为x的地址)。把pX的值赋予pY,所以pY也指向x。最后,在语句*pY = 20中,把值20赋予pY指向的地址。实际上是把x的内容改为20,因为pY指向x。注意在这里,变量pY和x之间没有任何关系。只是此时pY碰巧指向存储x的存储单元而已。
要进一步理解这个过程,假定x存储在堆栈的存储单元0x12F8C4到0x12F8C7中(十进制就是1243332到1243335,即有4B,因为int占用4B)。因为堆栈向下分配内存,所以变量pX存储在0x12F8C0到 0x12F8C3的位置上,pY存储在0x12F8BC 到 0x12F8BF的位置上。注意,pX和pY也分别占用4B。这不是因为int占用4B,而是因为在32位处理器上,需要用4B存储一个地址。利用这些地址,在执行完上述代码后,堆栈应如图7-5所示。
图 7-5
注意:
这个示例使用的是int来说明该过程,其中int存储在32位处理器中堆栈的连续空间上,但并不是所有的数据类型都会存储在连续的空间中。原因是32位处理器最擅长于在4B的内存块中获取数据。这种机器上的内存会分解为4字节的块,在Windows上,每个块都时常称为DWORD,因为这是32位无符号int在.NET出现之前的名字。这是从内存中获取DWORD的最高效的方式—— 跨越DWORD边界存储数据通常会降低硬件的性能。因此,.NET运行库通常会给某些数据类型加上一些空间,使它们占用的内存是4B的倍数。例如,short数据占用2B,但如果把一个short放在堆栈中,堆栈指针仍会减少4,而不是2,这样,下一个存储在堆栈中的变量就仍从DWORD的边界开始存储。
可以把指针声明为任意一种数据类型—— 即任何预定义的数据类型uint、int和byte等,也可以声明为一个结构。但是不能把指针声明为一个类或数组,这是因为这么做会使垃圾收集器出现问题。为了正常工作,垃圾收集器需要知道在堆上创建了什么类实例,它们在什么地方。但如果代码使用指针处理类,将很容易破坏堆中.NET运行库为垃圾收集器维护的、与类相关的信息。在这里,垃圾收集器可以访问的数据类型称为托管类型,而指针只能声明为非托管类型,因为垃圾收集器不能处理它们。
3. 将指针转换为整数类型
由于指针实际上存储了一个表示地址的整数,所以任何指针中的地址都可以转换为任何整数类型。指针到整数类型的转换必须是显式指定的,隐式的转换是不允许的。例如,编写下面的代码是合法的:
int x = 10;
int* pX, pY;
pX = &x;
pY = pX;
*pY = 20;
uint y = (uint)pX;
int* pD = (int*)y;
把指针pX中包含的地址转换为一个uint,存储在变量y中。接着把y转换回int*,存储在新变量pD中。因此pD也指向x的值。
把指针的值转换为整数类型的主要原因是为了显示它。Console.Write()和Console. WriteLine()方法没有任何带指针的重载方法,所以必须把指针转换为整数类型,才能接受和显示它们:
Console.WriteLine("Address is" + pX); // wrong – will give a
// compilation error
Console.WriteLine("Address is" + (uint) pX); // OK
可以把一个指针转换为任何整数类型,但是,因为在32位系统上,地址占用4B,把指针转换为不是uint、long 或 ulong的数据类型,肯定会导致溢出错误(int也可能导致这个问题,因为它的取值范围是–20亿到20亿,而地址的取值范围是0到40亿)。C#是用于64位处理器的,地址占用8B。因此在这样的系统上,把指针转换为非ulong的类型,就可能导致溢出错误。还要注意,checked关键字不能用于涉及指针的转换。对于这种转换,即使在checked情况下,发生溢出时也不会抛出异常。.NET运行库假定,如果要使用指针,就知道自己要做什么,并希望出现溢出。
4. 指针类型之间的转换
也可以在指向不同类型的指针之间进行显式的转换。例如:
byte aByte = 8;
byte* pByte= &aByte;
double* pDouble = (double*)pByte;
这是一段合法的代码,但如果要执行这段代码,就要小心了。在上面的示例中,如果要查找指针pDouble指向的double,就会查找包含1B的内存,并和一些其他内存合并在一起,把它当作包含一个double的内存区域来对待—— 这不会得到一个有意义的值。但是,可以在类型之间转换,实现类型的统一,或者把指针转换为其他类型,例如把指针转换为sbyte,检查内存的单个字节。
5. void指针
如果要使用一个指针,但不希望指定它指向的数据类型,就可以把指针声明为void:
int* pointerToInt;
void* pointerToVoid;
pointerToVoid = (void*)pointerToInt;
void型指针的主要用途是调用需要void*型参数的API函数。在C#语言中,使用void指针的情况并不是很多。特殊情况下,如果试图使用*运算符间接引用void指针,编译器就会标记一个错误。
6. 指针的算法
可以给指针加减整数。但是,编译器很智能,知道如何执行这个操作。例如,假定有一个int指针,要在其值上加1。编译器会假定要查找int后面的存储单元,因此会给该值加上4B, 即加上int的字节数。如果这是一个double指针,加1就表示在指针的值上加8B,即double的字节数。只有指针是指向byte或 sbyte(都是1B),才会给该指针的值加上1。
可以对指针使用运算符+、–、+=、–=、++和–– ,这些运算符右边的变量必须是long或ulong类型。
注意:
不允许针对void指针执行算术运算。
例如,假定有如下定义:
uint u = 3;
byte b = 8;
double d = 10.0;
uint* pUint= &u; // size of a uint is 4
byte* pByte = &b; // size of a byte is 1
double* pDouble = &d; // size of a double is 8
下面假定这些指针的地址是:
● pUint:1243332
● pByte: 1243328
● pDouble: 1243320
执行这段代码后:
++pUint; // adds (1*4)= 4 bytes to pUint
pByte–= 3; // subtracts (3*1)=3 bytes from pByte
double* pDouble2 = pDouble + 4; // pDouble2 = pDouble + 32 bytes (4*8 bytes)
指针应包含的内容是:
● pUint: 1243336
● pByte: 1243325
● pDouble2: 1243352
提示:
给类型为T的指针加上X,其中X的值为P,则得到的结果是P + X*(sizeof(T))。
注意:
使用这个规则时要小心。如果给定类型的连续值存储在连续的存储单元中,指针加法就允许在存储单元中移动。但如果类型是byte或char,其总字节数就不是4的倍数,在默认情况下,连续值就不是默认地存储在连续的存储单元中。
如果两个指针都指向相同的数据类型,也可以把一个指针从另一个指针中减去。此时,结果是一个long,其值是指针值的差被该数据类型所占用的字节数整除的结果:
double* pD1 = (double*)1243324; // note that it is perfectly valid to
// initialize a pointer like this.
double* pD2 = (double*)1243300;
long L = pD1-pD2; // gives the result 3 (=24/sizeof(double))
|
|
|
[Original]
[Print]
[Top]
|
|
[Original]
[Print]
[Top]
|
C#中的内存管理和指针(四)
7. sizeof运算符
在这一节中,将介绍如何确定各种数据类型的大小。如果需要在代码中使用类型的大小,就可以使用sizeof运算符,它的参数是数据类型的名称,返回该类型占用的字节数。例如:
int x = sizeof(double);
这将设置x的值为8。
使用sizeof的优点是不必在代码中硬编码数据类型的大小,使代码的移植性更强。对于预定义的数据类型,sizeof返回表7-1所示的值。
表 7-1
sizeof(sbyte) = 1; sizeof(byte) = 1;
sizeof(short) = 2; sizeof(ushort) = 2;
sizeof(int) = 4; sizeof(uint) = 4;
sizeof(long) = 8; sizeof(ulong) = 8;
sizeof(char) = 2; sizeof(float) = 4;
sizeof(double) = 8; sizeof(bool) = 1;
也可以对自己定义的结构使用sizeof,但此时得到的结果取决于结构中的字段。不能对类使用sizeof。它只能用于不安全的代码块。
8. 结构指针:指针成员访问运算符
结构指针的工作方式与预定义值类型的指针的工作方式是一样的。但是这有一个条件:结构不能包含任何引用类型,这是因为前面介绍的一个限制—— 指针不能指向任何引用类型。为了避免这种情况,如果创建一个指针,它指向包含引用类型的结构,编译器就会标记一个错误。
假定定义了如下结构:
struct MyStruct
{
public long X;
public float F;
}
就可以给它定义一个指针:
MyStruct* pStruct;
对其进行初始化:
MyStruct Struct = new MyStruct();
pStruct = &Struct;
也可以通过指针访问结构的成员值:
(*pStruct).X = 4;
(*pStruct).F = 3.4f;
但是,这个语法有点复杂。因此,C#定义了另一个运算符,用一种比较简单的语法,通过指针访问结构的成员,该语法称为指针成员访问运算符,其符号是一个短划线,后跟一个大于号:–>。
注意:
C++开发人员会认出指针成员访问操作符。因为C++使用这些符号完成相同的任务。
使用这个指针成员访问运算符,上述代码可以重写为:
pStruct–>X = 4;
pStruct–>F = 3.4f;
也可以直接把合适类型的指针设置为指向结构中的一个字段:
long* pL = &(Struct.X);
float* pF = &(Struct.F);
或者
long* pL = &(pStruct–>X);
float* pF = &(pStruct–>F);
9. 类成员指针
前面说过,不能创建指向类的指针,这是因为垃圾收集器不包含指针的任何信息,只包含引用的信息,因此创建指向类的指针会使垃圾收集器不能正常工作。
但是,大多数类都包含值类型的成员,可以为这些值类型成员创建指针,但这需要一种特殊的语法。例如,假定把上面示例中的结构重写为类:
class MyClass
{
public long X;
public float F;
}
然后就可以为它的字段X和F创建指针了,方法与前面一样。但这么做会抛出一个编译 错误:
MyClass myObject = new MyClass();
long* pL = &( myObject.X); // wrong– –compilation error
float* pF = &( myObject.F); // wrong– –compilation error
X和F本身都是非托管类型,它们嵌入在一个对象中,存储在堆上。在垃圾收集的过程中,垃圾收集器会把MyClass移动到内存的一个新单元上,这样, pL和pF就会指向错误的存储单元。由于存在这个问题,所以编译器不允许以这种方式把托管类型的成员地址分配给指针。
解决这个问题的方法是使用fixed关键字,它会告诉垃圾收集器,类实例的某些成员有指向它们的指针,所以这些实例不能移动。如果要声明一个指针,使用fixed的语法如下所示:
MyClass myObject = new MyClass();
fixed (long* pObject = &( myObject.X))
{
// do something
}
在关键字fixed后面的圆括号中,定义和初始化指针变量。这个指针变量(在本例中是pObject)现在就在fixed块的作用域内,这样,垃圾收集器知道,在执行fixed块中的代码时,不能移动MyObject对象。
如果要声明多个这样的指针,可以在同一个代码块前放置多个fixed语句:
MyClass myObject = new MyClass();
fixed (long* pX = &( myObject.X))
fixed (float* pF = &( myObject.F))
{
// do something
}
如果要在不同的阶段固定几个指针,还可以嵌套整个fixed块:
MyClass myObject = new MyClass();
fixed (long* pX = &( myObject.X))
{
// do something with pX
fixed (float* pF = &( myObject.F))
{
// do something else with pF
}
}
也可以在同一个fixed语句中初始化多个变量,但这些变量的类型必须相同:
MyClass myObject = new MyClass();
MyClass myObject2 = new MyClass();
fixed (long* pX = &( myObject.X), pX2 = &( myObject2.X))
{
// etc.
在上述情况中,是否声明不同的指针,让它们指向相同或不同对象中的字段,或者指向不与类实例相关的静态字段,这一点是不重要的。
10. 指针示例PointerPlayaround
下面给出一个使用指针的示例:PointerPlayaround。它执行一些简单的指针操作,显示结果,还允许查看内存中发生的情况,并确定变量存储在什么地方:
using System;
namespace Wrox.ProCSharp.Chapter07
{
class MainEntryPoint
{
static unsafe void Main()
{
int x=10;
short y =–1;
byte y2 = 4;
double z = 1.5;
int* pX = &x;
short* pY = &y;
double* pZ = &z;
Console.WriteLine(
"Address of x is 0x{0:X}, size is {1}, value is {2}",
(uint)&x, sizeof(int), x);
Console.WriteLine(
"Address of y is 0x{0:X}, size is {1}, value is {2}",
(uint)&y, sizeof(short), y);
Console.WriteLine(
"Address of y2 is 0x{0:X}, size is {1}, value is {2}",
(uint)&y2, sizeof(byte), y2);
Console.WriteLine(
"Address of z is 0x{0:X}, size is {1}, value is {2}",
(uint)&z, sizeof(double), z);
Console.WriteLine(
"Address of pX=&x is 0x{0:X}, size is {1}, value is 0x{2:X}",
(uint)&pX, sizeof(int*), (uint)pX);
Console.WriteLine(
"Address of pY=&y is 0x{0:X}, size is {1}, value is 0x{2:X}",
(uint)&pY, sizeof(short*), (uint)pY);
Console.WriteLine(
"Address of pZ=&z is 0x{0:X}, size is {1}, value is 0x{2:X}",
(uint)&pZ, sizeof(double*), (uint)pZ);
*pX = 20;
Console.WriteLine("After setting *pX, x = {0}", x);
Console.WriteLine("*pX = {0}", *pX);
pZ = (double*)pX;
Console.WriteLine("x treated as a double = {0}", *pZ);
Console.ReadLine();
}
}
}
这段代码声明了3个值变量:
● int x
● short y
● double z
还声明了指向这3个值的指针:px、py、pz。
然后显示这3个变量的值,以及它们的大小和地址。注意在获取px, py和pz的地址时,我们查看的是指针的指针,即值的地址的地址!还要注意,与显示地址的常见方式一致,在Console.WriteLine()命令中使用{0:X}格式说明符,确保该内存地址以16进制格式显示。
最后,使用指针px把x的值改为20,执行一些指针转换,如果把x的内容当作double类型,就会得到无意义的结果。
编译运行这段代码,在得到的结果中,我们将列出用/unsafe标志进行编译和不用/unsafe标志进行编译的结果:
csc PointerPlayaround.cs
Microsoft (R) Visual C# .NET Compiler version 7.10.3052.4
for Microsoft (R) .NET Framework version 1.1.4322
Copyright (C) Microsoft Corporation 2001–2002. All rights reserved.
PointerPlayaround.cs(7,26): error CS0227: Unsafe code may only appear if
compiling with /unsafe
csc /unsafe PointerPlayaround.cs
Microsoft (R) Visual C# .NET Compiler version 7.10.3052.4
for Microsoft (R) .NET Framework version 1.1.4322
Copyright (C) Microsoft Corporation 2001-2002. All rights reserved.
PointerPlayaround
Address of x is 0x12F8C4, size is 4, value is 10
Address of y is 0x12F8C0, size is 2, value is -1
Address of y2 is 0x12F8BC, size is 1, value is 4
Address of z is 0x12F8B4, size is 8, value is 1.5
Address of pX=&x is 0x12F8B0, size is 4, value is 0x12F8C4
Address of pY=&y is 0x12F8AC, size is 4, value is 0x12F8C0
Address of pZ=&z is 0x12F8A8, size is 4, value is 0x12F8B4
After setting *pX, x = 20
*pX = 20
x treated as a double = 2.63837073472194E-308
检查这3个结果,可以证实我们在本文前面的“后台内存管理”一节描述的堆栈操作,即堆栈给变量向下分配内存。注意,这还证实了堆栈中的内存块总是按照4B的倍数进行分配的。例如,y是一个short(size = 2),其地址是1243328,表示为该变量分配的内存区域是1243328~1243331。如果.NET运行库严格逐个排列变量,则y应只占用2个存储单元12433328和1243329。
11. 给示例添加类和结构
在本节中,使用第二个示例PointerPlayaround2介绍指针的算法,以及结构指针和类成员指针。开始时,定义一个结构CurrencyStruct,把货币值表示为美元和美分,再定义一个对应的类CurrencyClass:
struct CurrencyStruct
{
public long Dollars;
public byte Cents;
public override string ToString()
{
return "$" + Dollars + "." + Cents;
}
}
class CurrencyClass
{
public long Dollars;
public byte Cents;
public override string ToString()
{
return "$" + Dollars + "." + Cents;
}
}
定义好了结构和类后,就可以对它们应用指针了。下面的代码是一个新的示例。这段代码比较长,我们对此将做详细讲解。首先显示CurrencyStruct结构的字节数,创建它的两个实例和一些指针,再使用pAmount指针初始化一个CurrencyStruct结构amount1,显示变量的 地址:
public static unsafe void Main()
{
Console.WriteLine(
"Size of Currency struct is " + sizeof(CurrencyStruct));
CurrencyStruct amount1, amount2;
CurrencyStruct* pAmount = &amount1;
long* pDollars = &(pAmount->Dollars);
byte* pCents = &(pAmount->Cents);
Console.WriteLine("Address of amount1 is 0x{0:X}", (uint)&amount1);
Console.WriteLine("Address of amount2 is 0x{0:X}", (uint)&amount2);
Console.WriteLine("Address of pAmount is 0x{0:X}", (uint)&pAmount);
Console.WriteLine("Address of pDollars is 0x{0:X}", (uint)&pDollars);
Console.WriteLine("Address of pCents is 0x{0:X}", (uint)&pCents);
pAmount–>Dollars = 20;
*pCents = 50;
Console.WriteLine("amount1 contains " + amount1);
现在根据堆栈的工作方式,执行一些指针操作。由于变量是按顺序声明的,所以amount2存储在amount1后面紧邻的地址上,sizeof(CurrencyStruct)返回16(见后面的的屏幕输出),所以CurrencyStruct占用的字节数是4的倍数。在递减了Currency指针后,它就指向amount2:
–– pAmount; // this should get it to point to amount2
Console.WriteLine("amount2 has address 0x{0:X} and contains {1}",
(uint)pAmount, *pAmount);
在调用Console.WriteLine()语句时,它显示了amount2的内容,但还没有对它进行初始化。显示出来的东西就是随机的垃圾—— 在执行该示例前存储在内存中该单元的内容。但这有一个要点:一般情况下,C#编译器会禁止使用未初始化的值,但在开始使用指针时,就很容易绕过所有通常的编译检查。此时我们这么做,是因为编译器无法知道我们实际上要显示的是amount2的内容。因为知道了堆栈的工作方式,所以可以说出递减pAmount的结果是什么。使用指针算法后,可以访问各种编译器通常禁止访问的变量和存储单元,因此指针算法是不安全的。
在示例中,接下来在pCents指针上进行指针运算。pCents目前指向amount1.Cents,但此处的目的是使用指针算法让它指向amount2.Cents,而不是直接告诉编译器我们要做什么。为此,需要从pCents指针所包含的地址中减去sizeof(Currency):
// do some clever casting to get pCents to point to cents
// inside amount2
CurrencyStruct* pTempCurrency = (CurrencyStruct*)pCents;
pCents = (byte*) (–– pTempCurrency );
Console.WriteLine("Address of pCents is now 0x{0:X}", (uint)&pCents);
最后,使用fixed关键字创建一些指向类实例中字段的指针,使用这些指针设置这个实例的值。注意,这也是我们第一次能够查看存储在堆中(而不是堆栈)的项目地址:
Console.WriteLine("
Now with classes");
// now try it out with classes
CurrencyClass amount3 = new CurrencyClass();
fixed(long* pDollars2 = &(amount3.Dollars))
fixed(byte* pCents2 = &(amount3.Cents))
{
Console.WriteLine(
"amount3.Dollars has address 0x{0:X}", (uint)pDollars2);
Console.WriteLine(
"amount3.Cents has address 0x{0:X}", (uint) pCents2);
*pDollars2 = -100;
Console.WriteLine("amount3 contains " + amount3);
}
编译并运行这段代码,得到如下所示的结果:
csc /unsafe PointerPlayaround2.cs
Microsoft (R) Visual C# .NET Compiler version 7.10.3052.4
for Microsoft (R) .NET Framework version 1.1.4322
Copyright (C) Microsoft Corporation 2001–2002. All rights reserved.
PointerPlayaround2
Size of Currency struct is 16
Address of amount1 is 0x12F698
Address of amount2 is 0x12F688
Address of pAmount is 0x12F684
Address of pDollars is 0x12F680
Address of pCents is 0x12F67C
amount1 contains $20.50
amount2 has address 0x12F688 and contains $0.236
Address of pCents is now 0x12F67C
Now with classes
amount3.Dollars has address 0xB8850C
amount3.Cents has address 0x4B88514
amount3 contains $–100.0
注意:
这些结果是使用.NET Framework 1.1版本得到的。如果在.NET的另一个版本上运行该例子,实际显示的地址会有所不同。
注意在这个结果中,显示了未初始化的amount2值,CurrencyStruct结构的字节数是16,大于其字段的字节数(1 long(=8) + 1 byte(=1))。这是前面讨论的对齐单词的结果。
|
|
|
[Original]
[Print]
[Top]
|
|
[Original]
[Print]
[Top]
|
C#中的内存管理和指针(五)
7.3.2 使用指针优化性能
前面用许多篇幅介绍了使用指针可以完成的各种任务,但在前面的示例中,仅是处理内存,让有兴趣的人们了解底层发生了什么事,并没有帮助人们编写出好的代码!本节将应用我们对指针的理解,用一个示例来说明使用指针可以大大提高性能。
1. 创建基于堆栈的数组
本节将介绍指针的一个主要应用领域:在堆栈中创建高性能、低系统开销的数组。C#很容易使用一维数组和矩形或锯齿形多维数组,但有一个缺点:这些数组实际上都是对象,是System.Array的实例。因此数组只能存储在堆上,会增加系统开销。有时,我们希望创建一个使用时间比较短的高性能数组,不希望有引用对象的系统开销。而使用指针就可以做到,但只能用于一维数组。
为了创建一个高性能的数组,需要使用另一个关键字:stackalloc。stackalloc命令指示.NET运行库分配堆栈上一定量的内存。在调用它时,需要为它提供两条信息:
● 要存储的数据类型
● 需要存储的数据个数。
例如,分配足够的内存,以存储10个decimal数据,可以编写下面的代码:
decimal* pDecimals = stackalloc decimal [10];
注意,这个命令只是分配堆栈内存而已。它不会试图把内存初始化为任何默认值,这正好符合我们的目的。因为这是一个高性能的数组,给它不必要地初始化值会降低性能。
同样,要存储20个double数据,可以编写下面的代码:
double* pDoubles = stackalloc double [20];
虽然这行代码指定把变量的个数存储为一个常数,但它是在运行时计算的一个数字。所以可以把上面的示例写为:
int size;
size = 20; // or some other value calculated at run-time
double* pDoubles = stackalloc double [size];
从这些代码段中可以看出,stackalloc的语法有点不寻常。它的后面紧跟的是要存储的数据类型名(该数据类型必须是一个值类型),其后是把需要的变量个数放在方括号中。分配的字节数是变量个数乘以sizeof(数据类型)。在这里,使用方括号表示这是一个数组。如果给20个double数据分配存储单元,就得到了一个有20个元素的double数组,最简单的数组类型可以是:逐个存储元素的内存块,如图7-6所示。
图 7-6
在图7-6中,显示了一个由stackalloc返回的指针,stackalloc总是返回分配数据类型的指针,它指向新分配内存块的顶部。要使用这个内存块,可以取消对返回指针的引用。例如,给20个double数据分配内存后,把第一个元素(数组中的元素0)设置为3.0,可以编写下面的代码:
double* pDoubles = stackalloc double [20];
*pDoubles = 3.0;
要访问数组的下一个元素,可以使用指针算法。如前所述,如果给一个指针加1,它的值就会增加其数据类型的字节数。在本例中,就会把指针指向下一个空闲存储单元。因此可以把数组的第二个元素(数组中元素号为1)设置为8.4:
double* pDoubles = stackalloc double [20];
*pDoubles = 3.0;
*(pDoubles+1) = 8.4;
同样,可以用表达式*(pDoubles+X)获得数组中下标为X的元素。
这样,就得到一种访问数组中元素的方式,但对于一般目的,使用这种语法过于复杂。C#为此定义了另一种语法。对指针应用方括号时,C#为方括号提供了一种非常明确的含义。如果变量p是任意指针类型,X是一个整数,表达式p[X]就被编译器解释为*(p+X),这适用于所有的指针,不仅仅是用stackalloc初始化的指针。利用这个简捷的记号,就可以用一种非常方便的方式访问数组。实际上,访问基于堆栈的一维数组所使用的语法与访问基于堆的、由System.Array类表示的数组是一样的:
double *pDoubles = stackalloc double [20];
pDoubles[0] = 3.0; // pDoubles[0] is the same as *pDoubles
pDoubles[1] = 8.4; // pDoubles[1] is the same as *(pDoubles+1)
注意:
把数组的语法应用于指针并不是新东西。自从开发出C和C++语言以来,它们就是这两种语言的基础部分。实际上,C++开发人员会把这里用stackalloc获得的、基于堆栈的数组完全等同于传统的基于堆栈的C和C++数组。这个语法和指针与数组的链接方式是C语言在70年代后期流行起来的原因之一,也是指针的使用成为C和C++中一种大众化编程技巧的主要原因。
高性能的数组可以用与一般C#数组相同的方式访问,但需要强调其中的一个警告。在C#中,下面的代码会抛出一个异常:
double [] myDoubleArray = new double [20];
myDoubleArray[50] = 3.0;
抛出异常的原因很明显。使用越界的下标来访问数组:下标是50,但允许的最大值是19。但是,如果使用stackalloc声明了一个相同数组,对数组进行边界检查时,这个数组中没有包装任何对象,因此下面的代码不会抛出异常:
double* pDoubles = stackalloc double [20];
pDoubles[50] = 3.0;
在这段代码中,我们分配了足够的内存来存储20个double类型数据。接着把sizeof(double)存储单元的起始位置设置为该存储单元的起始位置加上50*sizeof(double)存储单元,来保存双精度值3.0。但这个存储单元超出了刚才为double分配的内存区域。谁也不知道这个地址上存储了什么数据。最好是只使用某个当前未使用的内存,但所重写的空间也有可能是堆栈上用于存储其他变量或某个正在执行的方法的返回地址。因此,使用指针获得高性能的同时,也会付出一些代价:需要确保自己知道在做什么,否则就会抛出非常古怪的运行时错误。
2. 示例QuickArray
下面用一个stackalloc示例QuickArray来结束关于指针的讨论。在这个示例中,程序仅要求用户提供为数组分配的元素数。然后代码使用stackalloc给long型数组分配一定的存储单元。这个数组的元素是从0开始的整数的平方,结果显示在控制台上:
using System;
namespace Wrox.ProCSharp.Chapter07
{
class MainEntryPoint
{
static unsafe void Main()
{
Console.Write("How big an array do you want?
> ");
string userInput = Console.ReadLine();
uint size = uint.Parse(userInput);
long* pArray = stackalloc long [(int)size];
for (int i=0 ; i<size ; i++)
pArray[i] = i*i;
for (int i=0 ; i<size ; i++)
Console.WriteLine("Element {0} = {1}", i, *(pArray+i));
}
}
}
运行这个示例,得到如下所示的结果:
QuickArray
How big an array do you want?
> 15
Element 0 = 0
Element 1 = 1
Element 2 = 4
Element 3 = 9
Element 4 = 16
Element 5 = 25
Element 6 = 36
Element 7 = 49
Element 8 = 64
Element 9 = 81
Element 10 = 100
Element 11 = 121
Element 12 = 144
Element 13 = 169
Element 14 = 196
7.4 小结
要想成为真正优秀的C#程序员,必须牢固掌握存储单元和垃圾收集的工作原理。本文描述了CLR管理以及在堆和堆栈上分配内存的方式,讨论了如何编写正确释放未托管资源的类,并介绍如何在C#中使用指针,这些都是很难理解的高级主题,初学者常常不能正确实现。
本文摘至清华大学出版社出版的Wrox红皮书《C#高级编程(第3版)》,转载必须标明出处
|
|
[Original]
[Print]
[Top]
|
|
|