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

ASP.NET 实用多线程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (47投票s)

2015年12月29日

CPOL

9分钟阅读

viewsIcon

85934

downloadIcon

826

在基于 ASP.NET 核心平台构建的应用程序中处理多线程问题的技术

引言

在本文中,我将演示一些在 ASP.NET 世界中处理多线程问题的技术。内容和代码片段适用于构建在 ASP.NET 核心平台之上的两大主要框架 - ASP.NET MVC 和 ASP.NET Web Forms。 

ASP.NET 平台本身是多线程的,并提供了编程模型,使我们免于处理使用线程的复杂性。但是,为什么我们需要关心底层以及 Web 应用程序中的所有线程是如何工作的呢?我们何时以及为何需要了解平台的线程模型?如何正确利用它?我将在本文中尝试回答这些问题,但让我们先快速回顾一下在深入 ASP.NET 世界的多线程和异步编程之前需要理解的主要概念。 

每个 ASP.NET Web 应用程序都有自己的线程池,用于处理请求。此线程池的限制可以在 Web 服务器级别以及应用程序级别进行配置。但是,正如您从上一句话中所注意到的,存在限制,更多的线程不一定等于更好、更快的应用程序。当请求到达 ASP.NET Web 应用程序时,会从线程池中提取一个线程,并在整个框架管道中使用它来处理该请求。只要一个线程正在处理,它就无法服务另一个请求。让我向您描述一个可怕的景象。想象一个负载很重的应用程序,偶尔会出现负载高峰,导致成千上万个并发请求。如果请求处理时间足够长且请求数量足够多,就会出现一种称为线程饥饿的状况 - 没有可用的工作线程。结果,IIS 开始排队新到达的请求,应用程序性能开始逐渐下降。当请求队列已满时,我们的 Web 服务器将开始拒绝新请求,我们的应用程序也将被称为“宕机”。  

在接下来的段落中,我将介绍几种可能导致应用程序出现上述状况的场景,以及我们可以采取哪些措施来防止应用程序性能下降甚至变得不可用。

问题 1

我们有一个显示天气预报数据的应用程序。这些数据是从天气数据提供商那里获取的,该提供商通过 HTTP 服务进行通信。在我们的主页的每次页面加载时,我们需要调用该 Web 服务并显示获取的数据。此问题是阻塞 I/O 请求的一个示例。如果我们同步处理此请求(在从池中分配的初始线程上),我们实际上是在阻塞一个线程池线程,使其空闲等待。在操作系统级别存在异步 I/O 操作的机制,这些机制已很好地集成到 .NET API 和 CLR 中。当我们需要在请求中执行 阻塞网络或 I/O 操作 并在客户端处理/显示结果时,推荐的方法是 使用异步控制器或页面(总的来说,异步 HTTP 处理程序,这是两大框架在某种程度上都提供的)。请查看下面部分的代码片段,以解决此问题。 

解决方案

ASP.NET MVC (4 及以上版本) 代码片段

    public class WeatherController : Controller
    {
        private string weatherProviderUrl = "http://ourweatherprovider.com/api/get";

        public async Task<ActionResult> Get()
        {
            using (HttpClient httpClient = new HttpClient())
            {
                var response = await httpClient.GetAsync(this.weatherProviderUrl);
                string jsonResp = await response.Content.ReadAsStringAsync();
                ForecastVM forecastModel = 
                await JsonConvert.DeserializeObjectAsync<ForecastVM>(jsonResp);
                return View(forecastModel);
            }
        }
    }

代码注释

上面的代码从天气 Web 服务获取所有数据,并用它创建一个视图模型,然后将其传递给视图。所有网络工作都是异步完成的,从而解决了我们阻塞和浪费线程的问题。从我的角度来看,MVC 框架在使用异步编程模型方面是最容易的。

ASP.NET Web Forms (4.5 及以上版本) 代码片段

public partial class Weather : System.Web.UI.Page
{
    private string weatherProviderUrl = "http://ourweatherprovider.com/api/get";

    protected void Page_Load(object sender, EventArgs e)
    {
        RegisterAsyncTask(new PageAsyncTask(GetWeatherData));
    }

    public async Task GetWeatherData()
    {
        using (HttpClient httpClient = new HttpClient())
        {
            var response = await httpClient.GetAsync(this.weatherProviderUrl);
            string jsonResp = await response.Content.ReadAsStringAsync();
            ForecastVM forecastModel = 
               await JsonConvert.DeserializeObjectAsync<ForecastVM>(jsonResp);
            this.ourControl.DataSource = forecastModel;
            this.ourControl.DataBind();
        }
    }
}

请勿忘记

<%@ Page Async="true" Language="C#" AutoEventWireup="true" 
   CodeBehind="Weather.aspx.cs" Inherits="WebThreadingSample.Weather" %>

代码注释

自 ASP.NET 4.5 起,页面中的异步操作变得更加容易。上面的代码执行的操作与 MVC 代码相同,在功能上几乎产生相同的结果。数据绑定代码仅为示例的完整性。甚至还有更酷的功能(ASP.NET 4.6),例如异步模型绑定,现在已成为框架的一部分,但它们超出了本文的范围。

异步 HTTP 处理程序 (ASP.NET 4.5 及以上版本) 代码片段

    public class WeatherHandler : HttpTaskAsyncHandler
    {
        private string weatherProviderUrl = "http://ourweatherprovider.com/api/get";

        public override async System.Threading.Tasks.Task 
              ProcessRequestAsync(HttpContext context)
        {
            using (HttpClient httpClient = new HttpClient())
            {
                var response = await httpClient.GetAsync(this.weatherProviderUrl);
                string jsonResp = await response.Content.ReadAsStringAsync();
                context.Response.ContentType = "application/json; charset=utf-8";
                context.Response.Write(jsonResp);    
            }
        }
    }

代码注释

上面的代码提供了与其他示例相同的结果,唯一的区别是处理程序返回 JSON 响应,该响应可用于 Ajax 请求等。HTTP 处理程序在许多情况下都很有用,我将在当前文本中不讨论它们。Scott Hanselman 在 此处 有一篇关于它们的精彩文章。

我倾向于遵循的一个通用规则是,当 ASP.NET 线程在空闲时被阻塞时,在我的应用程序中使用异步编程。

问题 2

我们有一个医院医生和医院管理员用于管理患者的应用程序。当医生在系统中注册患者时,医院管理员会收到一封电子邮件,告知已发生注册。如果发送电子邮件不成功,消息将添加到数据库队列中,并在 5 分钟后再次发送。此问题也是 阻塞操作 的一个示例,但在这里 用户不关心此操作的结果,也不需要等待它。下面的代码片段适用于两大 Web 框架。

解决方案

//ct is CancellationToken object
HostingEnvironment.QueueBackgroundWorkItem
(ct => this.notificationService.SendRegisterNotificationsAsync(usersToNotify, patientID));

代码注释

上面的示例是一种“即发即弃”技术。当我们在当前请求中 不关心结果,并且所有处理和失败处理都在我们应用程序的其他地方进行时,可以在任何控制器或页面中使用它。该代码在另一个线程池线程上执行 SendRegisterNotificationsAsync 方法,并将要通知的用户 ID 列表以及新创建患者的患者 ID 传递过去。

这种方法在某些场景下很有用,但也有一些限制和注意事项需要指出

  • 该解决方案自 .NET 4.5.2 起可用。还有一些依赖第三方库的替代方案,我将在下面提到。
  • 当我们的应用程序回收 AppDomain 或在某处发生不可捕获的异常导致我们的进程终止时,如果 我们的小后台任务仍在运行,ASP.NET 会知道它,这是好事。坏事是 ASP.NET 会给它一个时间范围(90 秒或更短)来完成,如果它未完成,将在执行中间被取消。因此,对于后台任务来说,这不是最可靠的解决方案,应该根据场景明智地使用。从我的角度来看,它在简单性和可靠性之间取得了良好的平衡。
  • 还有 HangFire - HangFire 等替代方案,它们是更复杂、更高级的解决方案。
  • 我们可以使用 `Task.Run` 或 `ThreadPool.QueueUserWorkItem` 方法,并在池中的另一个线程上运行我们的方法,但这是最不可靠的解决方案,因为 ASP.NET 完全不知道当前正在执行一些工作,并且可以随时回收 AppDomain 而不进行任何通知。

关于此问题的更详细文章可以在 此处 找到。

问题 3

让我们回到第一个问题 - 显示从 Web 服务定期收集天气数据的应用程序。但现在我们将实现不同的方法。我们的天气 数据将按固定间隔收集,存储在 ASP.NET 缓存中,然后在每次请求时检索。下面的实现很简单,远非完美,但在需要简单、进程内、非关键任务的后台作业的某些场景中很有用。 

解决方案

    /// <summary>
    /// Every instance of this class represents a scheduled task 
    /// that is executed in the current AppDomain
    /// </summary>
    public class BackgroundJob : IRegisteredObject, IDisposable
    {
        private bool disposed = false;
        private System.Threading.Timer recurringTimer;
        private Action work;
        private int intervalInMs;
        private CancellationTokenSource cts;
        private Task workTask = null;
        private const int TIMEOUT_TO_KILL = 10000;

        /// <summary>
        /// When the instance is constructed the background job is automatically started.
        /// </summary>
        /// <param name="work">Pass method without parameters 
        /// that does not return a value to be executed the regularly.</param>
        /// <param name="intervalInSeconds">Interval in seconds between executions.</param>
        /// <param name="runImmediately">Flag that indicates 
        /// if method runs immediately after the instance is created or 
        /// waits until the interval elapses.</param>
        public BackgroundJob(Action work, int intervalInSeconds, bool runImmediately = true)
        {
            this.work = work;
            this.intervalInMs = intervalInSeconds * 1000;
            this.cts = new CancellationTokenSource();
            this.recurringTimer = new System.Threading.Timer(Callback, null,
                                                             runImmediately ? 
                                                            1 : this.intervalInMs,
                                                             Timeout.Infinite);
            HostingEnvironment.RegisterObject(this);
        }

        private async void Callback(object state)
        {
            try
            {
                this.workTask = new Task(() => work.Invoke());
                this.workTask.Start();
                var ct = this.cts.Token;
                await this.workTask.WithCancellation(ct);
            }
            catch(OperationCanceledException)
            { }
            finally
            {
                this.recurringTimer.Change(this.intervalInMs, Timeout.Infinite);
            }
        }

        public void Stop(bool immediate)
        {
            try
            {
                if (this.workTask != null && this.workTask.Status == TaskStatus.Running)
                {
                    this.cts.CancelAfter(TIMEOUT_TO_KILL);
                    this.workTask.Wait();
                }
            }
            finally
            {
                HostingEnvironment.UnregisterObject(this);
            }
        }

        //IDisposable implementation omitted for space saving
    }

这是我在上面的代码中用于取消任务的小型扩展助手。 

    public static class TaskExtensions
    {
        public static async Task WithCancellation
               (this Task originalTask, CancellationToken ct)
        {
            var cancelTask = new TaskCompletionSource<object>();
            using (ct.Register(t => 
                 ((TaskCompletionSource<object>)t).TrySetResult(new object()), cancelTask))
            {
                Task any = await Task.WhenAny(originalTask, cancelTask.Task);
                if (any == cancelTask.Task)
                    ct.ThrowIfCancellationRequested();
            }
            await originalTask;
        }
    }

代码注释

`BackgroundJob` 类的一个实例必须与 AppDomain 一样存活,以便重复执行您的任务。您可以在 global.asax 中使用该实例作为静态成员,并在 application_start 时创建它,或者采取任何其他保存应用程序状态和运行应用程序启动代码的方法。

此方法有与问题 2 类似的限制。我们需要注意 AppDomain 回收事件和未捕获的异常。我的建议是,对于关键的后台任务,使用 不属于 ASP.NET 应用程序的 进程外解决方案。有比这里介绍的更复杂的解决方案,但这完全取决于我们试图解决的问题。问题 2 和问题 3 在技术上非常相似,我们可以将它们结合起来,甚至简化上面的代码。但从“现实世界”的角度来看,我认为它们解决了两个不同的案例,每个案例都有不同的问题需要考虑。关于这个主题的精彩文章可以在 此处 & 此处 找到。

示例应用程序

根据社区的要求,我更新了本文,添加了一个示例 ASP.NET MVC 应用程序,演示了 `BackgroundJob` 类的使用。您可以看到后台任务是如何在 `Global.asax` 文件中初始化的。`IncrementState` 方法是实际重复执行的任务。

    public class MvcApplication : System.Web.HttpApplication
    {
        internal static int State { get; set; }
        private static BackgroundJob recurringJob;

        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            //Background task is initialized and started here
            recurringJob = new BackgroundJob(this.IncrementState, 3);
        }

        private void IncrementState()
        {
            Random rand = new Random();
            int nextRand = rand.Next();
            State = nextRand;
        }
    }

`State` 属性用于 Home 控制器的 `Index` 操作,并通过 `ViewBag` 传递给视图。

    public ActionResult Index()
    {
        this.ViewBag.IntState = MvcApplication.State;
        return View();
    }

通过刷新应用程序的 `/Home/Index` 页面,您实际上可以看到后台任务正在更改 `State` 属性。

结论

作为以上所有内容的总结,我将尝试提取一些 ASP.NET 开发人员可以安全遵循并谨慎打破的通用规则。

  • 当您拥有需要在处理请求过程中完成的 I/O 操作时,请使用异步代码。第一个示例问题中的代码片段非常适合此目的。
  • 避免在 ASP.NET 线程池中排队后台工作。如果您有理由这样做,请使用第二个示例问题中显示的作为最低限度的方法。
  • 如果您在 ASP.NET 应用程序中运行进程内后台任务,请注意并准备好可能的中断和工作丢失,因此在选择要以这种方式实现的任务类型时要非常谨慎。

历史

  • 2015 年 12 月 29 日:创建文章
  • 2016 年 1 月 23 日:添加了示例 ASP.NET MVC 应用程序,演示了 `BackgroundJob` 类的使用
© . All rights reserved.