65.9K
CodeProject 正在变化。 阅读更多。
Home

.Net 线程你需要知道的。

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (69投票s)

2014年9月20日

CPOL

15分钟阅读

viewsIcon

73465

downloadIcon

35

本文旨在介绍 .Net 中可用的线程概念。

大家好,我有一个非常长的文章主题列表。这次我选择讨论 .Net 中的线程,因为我使用 FIFO(先进先出)从列表中挑选了这个主题。您知道线程是一个永恒的话题,您可能已经看过很多关于线程的文章。但我想让这篇文章对我的朋友们来说非常有趣。所以,让我们开始我们的旅程吧。并非所有主题都会在这篇文章中涵盖。它将分块发布,从基础知识到 .Net 4.5 框架的线程功能。

start

引言

我遇到过很多人,他们知道什么是线程,但在实现时,他们不确定该使用哪种线程功能来完成他们的任务。如果您不知道 .Net 框架提供了什么,以及它的优缺点,那么判断总是会失败。在我开始使用线程进行开发时,我也是这种困惑的受害者。所以,本文的目的是用句号替换问号。首先,让我们了解线程的基础知识。

start

背景

在我们深入研究之前,我们需要了解什么是进程和线程,因为进程和线程是相辅相成的。进程是应用程序之间的隔离,以保护应用程序及其数据。它也是内存和资源的物理隔离。线程是执行进程内所有操作的实体;这意味着线程是赋予进程生命的实体。一旦您单击可执行文件,Windows 就会为该应用程序创建一个进程,并在进程内创建一个主线程来执行所有必需的初始化和后续执行。因此,我们可以得出结论,每个进程至少有一个线程。引入线程概念是为了使 Windows 保持响应。响应能力意味着用户可以看到许多应用程序在 Windows 中并行运行。

幕后

我们知道每个应用程序都在自己的进程中运行,每个进程至少有一个线程(主线程)。Windows 在应用程序之间以及进程内的线程之间进行切换,以使用户感觉在并行执行。这称为上下文切换。通过这一点,我们知道线程非常棒,可以在任何时候保持 Windows 的响应。但如果我们从负面角度看,上下文切换本身就有开销,可能导致性能下降。线程与内存和时间开销相关。每个线程都有以下其中一项。

  • 线程内核对象:操作系统为每个创建的线程分配这些数据结构,其中包含一组 CPU 寄存器。它还包含线程的上下文,在 X86 机器上占用 700 字节,在 X64 机器上占用 1240 字节。
  • 线程环境块 (TEB):这是在用户模式下分配和初始化的内存块。它在 X84 上占用 4KB,在 X64 上占用 8KB。它有助于存储线程的本地存储数据以及 GDI 和 OpenGL 图形中使用的图形用户界面 (GUI) 和 OpenGL 图形所需的数据结构。
  • 用户模式堆栈:用户模式堆栈用于局部变量和传递给方法的参数。当线程从方法返回时,它还包含要执行的下一条语句。默认情况下,Windows 分配 1MB 内存。
  • 内核模式堆栈:当应用程序代码将参数传递给操作系统中的内核模式函数时使用。这主要用于安全原因,即当 Windows 复制从用户模式堆栈传递到内核模式的任何数据时。在此过程之后,Windows 会验证数据并对其进行操作。它在 X86 上占用 12KB,在 X64 上占用 24KB。

考虑到所有这些开销,我们可以推断,每个创建的线程在时间和内存方面都扮演着非常重要的角色。所以,请仅在需要时创建线程。但我们大多数人都认为创建线程是一种高级的编程方式,这完全不正确。

什么是 CLR 线程和 Windows 线程?

到目前为止,CLR 线程直接映射到 Windows 线程。所以我们可以说 CLR 线程在内部利用 Windows 线程。您还应该注意,开发人员应避免直接使用 Windows 线程,因为有许多论坛指出 Microsoft 正在通过使 CLR 线程更轻量级、优化资源使用等方式来增强 CLR 线程。如果这是原因,那么我们将失去 CLR 线程的性能优势,并且可能会遇到许多其他问题。所以,建议始终使用 CLR 线程而不是 Windows 线程。这些关于线程的介绍和提醒足够开始学习了。所以,让我们深入研究并从我们古老的线程“祖母”开始。

start

线程 (System.Threading)

所以,在这里我们将从创建线程的最简单方法开始,即使用 System.Threading 命名空间中可用的 Thread 类。在这里,您可以通过创建 Thread 类的实例并将方法名传递给其构造函数来创建一个线程。当然,在构造函数中传递方法名不是强制性的,您可以显式地将方法名指定给线程对象。

	static void Main(string[] args)
	{
		// Create an instance of Thread class and pass the method in its constructor.
		Thread threadObj = new Thread(Compute);
		// Call Start method of the thread which begins executing the method.
		threadObj.Start();
		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}

	/// Performs some operation.
	private static void Compute(object obj)
	{
		for (int i = 0; i < 1000; i++)
		{
			Console.WriteLine("Thread executing " + i.ToString());
		}
	}

这是创建线程的典型方法,当您想执行计算密集型操作时。强烈建议避免此技术,并且始终建议使用 CLR 线程池,我们将在稍后讨论。仅当满足以下任一条件时,您才应创建显式线程:

  • 如果您的线程需要在非正常优先级下运行。
  • 如果您需要线程表现为前台线程。
  • 如果您想启动线程,并通过调用 Threads Abort 方法可能提前中止它。

注意:创建 Thread 对象是一个非常轻量级的操作。我们知道当 CLR 线程创建时,它会直接映射到 Windows 线程,但这个操作系统线程仅在您调用线程的“Start”方法时创建,而不是在您仅仅创建 Thread 对象时创建。既然我们遇到了前台线程,那么在我们继续之前,最好先了解一下什么是前台线程和后台线程。

什么是前台线程和后台线程

前台线程:前台线程不允许应用程序进程在完成其工作之前终止。因此,您应该使用前台线程来执行您确实希望完成的任务。

后台线程:这些线程与前台线程类似,但一旦您终止应用程序,它们就会随之消失。这意味着线程将在您关闭应用程序后立即终止。

注意:当您发现您的应用程序已关闭但进程仍在运行时,罪魁祸首就是前台线程。解决方案是除非您明确知道自己在做什么,否则永远不要将任何线程设置为前台线程。默认情况下,您使用 Thread 类创建的线程是前台线程。要将线程设置为后台线程,只需将 Thread 对象的 IsBackground 属性设置为 true。

通过执行以下代码片段,您可以轻松注意到区别。只需注释掉 IsBackground=true 行,即可了解前台线程如何工作。一旦您关闭应用程序,您就可以启动任务管理器并转到“进程”选项卡。您会发现应用程序进程仍在运行,因为线程正在循环遍历 For 循环,而我们给出的值非常大。当您取消注释该行并执行相同的步骤时,当应用程序关闭时,您可以发现应用程序进程会立即终止。

	static void Main(string[] args)
	{
		// Create an instance of Thread class and pass the method in its constructor.
		Thread threadObj = new Thread(Compute);
		// Set the IsBackground Property to tru. Comment this line to make it forground thread.
		threadObj.IsBackground = true;
		// Call Start method of the thread which begins executing the method.
		threadObj.Start();
		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}

	/// Performs some operation.
	private static void Compute(object obj)
	{
		for (int i = 0; i < 100000; i++)
		{
			Console.WriteLine("Thread executing " + i.ToString());
		}
	}

线程池 (CLR)

我们知道创建和销毁线程是性能方面最昂贵的操作。许多线程会消耗更多的内存资源,并导致更多的操作系统上下文切换。为了解决这个问题,CLR 提供了管理自己的线程池的选项。线程池是一组可供应用程序使用的线程。每个 CLR 都有自己的线程池,该 CLR 内的所有应用程序域都使用相同的线程池。一个进程可能加载了多个 CLR。因此,在这种情况下,每个 CLR 都会有一个单独的线程池。当应用程序需要执行某些操作时,您将该操作添加到线程池队列中,因为线程池有自己的队列,每个操作都从队列中分派,并分配一个线程池线程来执行该操作。如果线程池中没有线程,则会创建一个新线程来执行该操作。我们知道创建和销毁线程是昂贵的操作,但我们无法避免它。线程池的主要思想是在需要时创建线程并在池中维护线程。因此,下次如果有请求到达时,CLR 会使用相同的线程来执行操作,这将减少创建线程的开销,并且 CLR 也无需销毁它的开销。在某个点,线程池中的线程会进入空闲状态,如果没有请求,甚至会自行终止,从而释放所有内存资源。线程池将线程分类为:

  • 工作线程:当应用程序请求线程池执行异步操作(如访问文件系统、数据库、服务等)时使用。
  • I/O 线程:当异步操作完成时用于通知。

下面的代码演示了使用线程池执行一些异步操作。

	static void Main(string[] args)
	{
		Console.WriteLine(" Queueing an Operation");
		// Queue an operation the thread pool. After queuing the thread pool will automatically assign the thread to
		// perform the action. We need not explicitly invoke any method to perform the action.
		ThreadPool.QueueUserWorkItem(Compute);
		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}

	/// Performs some operation.
	private static void Compute(object obj)
	{
		for (int i = 0; i < 1000; i++)
		{
			Console.WriteLine("Thread Pool thread executing " + i.ToString());
		}
	}

Task Parallel Library

从 .NET 4.0 开始,Microsoft 将任务并行库 (TPL) 视为编写并行代码的首选方式。与以前的线程模型相比,该库大大简化了创建并行执行的工作。这使得利用我们工作和生活中的计算环境中日益普及的多核计算机变得更加容易。TPL 包含多个项目,可使并行化更容易。

Tasks(任务)

现在我们知道了使用 CLR 线程池相对于普通线程对象的优势。您还注意到创建线程池线程非常容易。但是线程池线程有其自身的限制。最大的问题是您无法知道操作何时完成,也无法在操作完成后获取返回值。这就是我们的救星 Mr. Task 登场的时刻。创建任务并将操作委托给它,就像创建线程池线程一样简单。我们使用 ThreadPool.QueueUserWorkItem (Operation Name)。要创建任务,我们只需创建一个 Task 对象,并在其构造函数中传递方法名或 Action 委托。下面的代码片段展示了如何创建任务并执行操作。

	static void Main(string[] args)
	{
		// Create a task object
		Task taskObj = new Task(o => Compute((int)o),1000);
		// Start the task
		taskObj.Start();
		// Get the result from the task completed
		int res = taskObj.Result;
		// Log the completed information
		Console.WriteLine("Task completed and the result is "+ res.ToString());
		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}

	/// Performs some operation.
	private static int Compute(object obj)
	{
		for (int i = 0; i < (int)obj; i++)
		{
			Console.WriteLine("Thread executing " + i.ToString());
		}

		return 1000;
	}

任务延续:我们开发人员应始终确保以智能的方式创建线程。在任务尚未完成执行时调用 Wait 或 Result 属性可能会导致创建新线程,从而增加资源使用量。有一个更好的方法可以在上一个任务完成后启动另一个任务。我们可以使用上一个任务的结果作为即将启动的新任务的输入,方法是使用 Task 中可用的 ContinueWith 方法。此 ContinueWith 方法返回一个新任务,该任务将在上一个任务完成后立即执行。Task 包含一个 ContinueWith 的集合,因此您可以对同一个 Task 调用多次 ContinueWith。当 Task 完成时,所有 ContinueWith 任务都将被排队到线程池。您还可以指定 TaskContinuationOptions,它指示您希望新任务执行的操作,例如:

  • OnlyOnCanceled:仅在第一个任务被取消时执行。
  • OnlyOnFaulted:仅当第一个任务抛出未处理的异常时执行。
  • OnlyOnRanToCompleteion:仅当第一个任务成功完成,而未被取消或抛出未处理的异常时执行。
	static void Main(string[] args)
	{
		// Create a task object
		Task taskObj = new Task(o => Compute((int)o), 1000);
		// Start the task
		taskObj.Start();
		// Continue with this task when first task gets completed. There are many TaskContinuationOptions which you can specify while 
		// creating ContinueWith method like OnlyOnFaulted, OnlyOnCanceled, LongRunning, Prefer Fairness and so on...
		Task nextTask = taskObj.ContinueWith(task => Console.WriteLine("Task completed and the result is " + taskObj.Result.ToString()),TaskContinuationOptions.OnlyOnRanToCompletion);
		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}
	
	/// Performs some operation.
	private static int Compute(object obj)
	{
		for (int i = 0; i < (int)obj; i++)
		{
			Console.WriteLine("Thread executing " + i.ToString());
		}

		return 1000;
	}

任务调度器:任务基础结构非常强大且灵活。任务调度器在此基础结构中发挥着重要作用。它负责执行所有计划的任务。有两种任务调度器类型:

  • 线程池任务调度器:此调度器将任务调度到线程池工作线程。
  • 同步上下文任务调度器:同步任务调度器主要用于 Windows Forms、WPF 和 Silverlight,它将所有任务调度到应用程序的 GUI 线程,以便所有任务代码都能成功更新 UI 组件。这根本不使用线程池。您可以使用 TaskScheduler.FromCurrentSynchronizationContext() 获取同步上下文。您可以在创建任务时使用此同步上下文,任务将在 GUI 线程上执行。注意:当您需要执行某些计算密集型操作时,您可以创建一个任务,该任务默认使用线程池线程执行操作。这样就不会阻塞 UI 线程。请确保不要使用正常任务更新 UI 元素,否则会抛出 InvalidOperationException。一旦您完成了操作,您就可以继续另一个任务,该任务接收当前的同步上下文,然后您可以更新 GUI 组件。

任务工厂:任务工厂用于创建共享相同状态的一组 Task 对象。为了避免您不得不一遍又一遍地将相同的参数传递给每个 Task 的构造函数,您可以创建一个封装了公共状态的任务工厂。您可以创建带或不带返回类型的任务工厂。

	static void Main(string[] args)
	{
		// Create a main task
		Task mainTask = new Task(()=> {

			var tokenSource = new CancellationTokenSource();
			var taskFactory = new TaskFactory(tokenSource.Token, TaskCreationOptions.AttachedToParent, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);

			var childTasks = new Task[] 
			{
				// use the task factory to create bunch of tasks with same settings.
				taskFactory.StartNew(()=> Compute(100)),
				taskFactory.StartNew(()=> Compute(1000)),
				taskFactory.StartNew(()=> Compute(10000))
			};
		});

		// start the main task
		mainTask.Start();

		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}

	/// Performs some operation.
	private static int Compute(object obj)
	{
		for (int i = 0; i < (int)obj; i++)
		{
			Console.WriteLine("Thread executing " + i.ToString());
		}

		return 1000;
	}

并行

这是一个静态帮助类,可以轻松地执行并行循环和方法调用,而无需直接创建任务或线程。为了简化编程,该类封装了使用 Task 对象内部的常见场景。当然,使用此类会有一些开销,但如果根据用例正确使用,它为并发执行操作提供了一种灵活的方式。所有 Parallel 的方法都让调用线程参与工作处理,这在资源使用方面非常好。

注意:并非所有工作都受益于并行化。如果每项工作项的执行时间相对微不足道,并行化实际上可能会降低性能。如有疑问,请保持串行,只有在确定需要时才并行化。也就是说,如果您确实需要并行化工作,TPL 是一套很棒的工具,而 VS2010 有一个很棒的并发分析器来帮助识别热点。

	static void Main(string[] args)
	{
		// These are the different ways of using Parallel constructs.

		List countList = new List() {3,4,8,10,6};

		// Executes the work in parallel starting from 0 to 10
		Parallel.For(0, 10, i=> Compute(i));

		// using foreach in the Parallel construct.
		Parallel.ForEach(countList, i=>Compute(i));

		// Invokes the Operation parallel.
		Parallel.Invoke(
			() => Compute(10),
			() => Compute(10),
			() => Compute(10));

		Console.WriteLine("Parallelsim completed");
		// This is required for Console window to stay even after completing the task.
		Console.ReadLine();
	}

	/// Performs some operation.
	private static int Compute(object obj)
	{
		for (int i = 0; i < (int)obj; i++)
		{
			Console.WriteLine("Thread executing " + i.ToString());
		}

		return 1000;
	}

性能监视器是衡量应用程序性能的最佳可用工具。您可以获得许多选项来监视系统上的每个处理器核心。由于我们处理的是并行性,因此我们需要根据每个处理器核心的利用率来衡量效率。该图显示了我系统中的两个处理器(红色和绿色线)。这是在上述代码利用任务并行性执行时捕获的。您可以看到这两个处理器是如何被充分利用的。

start

这是使用普通线程运行相同操作时捕获的图像。您可以看到处理器核心的使用率多么微不足道,并且只有一个处理器核心被充分利用。

start

您应该熟悉的线程概念

理解线程池

线程池是一组随时可供应用程序使用的线程。CLR 允许开发人员设置线程池可以创建的最大线程数。我们知道每个线程至少消耗 1MB 内存。假设一个 32 位操作系统的系统有 2GB 的可用地址空间。在加载了 Win32 DLL、CLR DLL、本机堆和托管堆之后,我们几乎只剩下 1.5GB 的地址空间。所以最多可以创建大约 1300 个线程。超过此限制,您将收到 OutOfMemoryException。考虑到所有条件,线程池中的默认线程数设置为 1000。

工作线程:线程池由工作线程和 I/O 线程组成。ThreadPool.QueueUserWorkItem 方法和 Timer 类始终将工作项排队到全局队列。工作线程使用 FIFO 算法从该队列中拉取项目并进行处理。由于所有工作线程都在操作此全局队列,因此所有工作线程都在争用线程同步锁,以确保两个或多个线程不会从队列中获取相同的工作项。

start

当非工作线程调度任务时,任务会被添加到全局队列。但是每个工作线程都有自己的本地队列,当工作线程调度任务时,任务会被添加到调用线程的本地队列中。当工作线程准备处理项目时,它首先会检查其本地队列中是否有任务。由于只有工作线程可以访问其本地队列,因此不需要同步锁。这些任务是使用 LIFO 执行的。如果工作线程的本地队列为空,它会尝试窃取另一个工作线程的本地队列,此时需要同步锁,这种情况很少发生。如果整个本地队列为空,线程将从全局队列中提取。如果全局队列也为空,它将进入空闲状态。如果它长时间休眠,它会唤醒并销毁自身,允许系统回收资源。

协作式取消

.Net Framework 提供了一种标准且高效的取消操作的模式。此模式是协作式的,意味着您要取消的操作必须显式支持被取消。代码片段展示了如何使用 Cancellation Token Source 执行取消。您甚至可以注册一个回调,在发生任何取消时进行通知。

	static void Main(string[] args)
	{
		// Create a cancellation source
		CancellationTokenSource cancelObj = new CancellationTokenSource();

		// User can even register to notify when cancellation occur
		cancelObj.Token.Register(() => CancelNotification());

		// queue the operaton with cancellation token
		ThreadPool.QueueUserWorkItem(o => Compute(100000, cancelObj.Token));

		// Wait for the user to cancel the operation.
		Console.WriteLine("Press enter to cancel the Operation");

		Console.ReadLine();

		// Cancel the operation
		cancelObj.Cancel();

		Console.ReadLine();

	}
  
	/// Performs some operation.
	private static int Compute(object obj, CancellationToken token)
	{
		for (int i = 0; i < (int)obj; i++)
		{
			// Poll and check for the token status.
			if (token.IsCancellationRequested)
			{
				break;
			}

			Console.WriteLine("Thread executing " + i.ToString());
		}

		return 1000;
	}

	/// Cancellation Notification handler
	private static void CancelNotification()
	{
		Console.WriteLine("The user operation is canceled");
	}
		

AggregateException:AggregateException 只是一个容器,用于容纳在使用 PLINQ 或 TPL 时可能抛出的一个或多个异常。由于异常可能在不同的线程上抛出,也可能并发发生,因此系统会自动捕获它们并在 AggregateException 包装器中重新抛出它们,以确保它们都在一个地方报告。异常本身通过 InnerExceptions 属性公开。

注意:I/O 异步操作(Begin、End)将在另一篇文章中介绍。

关注点

一如既往,写这篇文章真的很有趣,因为您可以借此机会更深入地探讨该主题。这个主题下还有很多内容需要介绍,但一篇文章不足以涵盖所有功能。我相信任何阅读本文的开发人员都将能够自信地高效地使用线程。

© . All rights reserved.