【.NET并发编程 - 17】Background Service 后台任务:并发编程的幕后英雄

17. Background Service 后台任务:并发编程的幕后英雄

本章 GitHub 仓库csharp-concurrency-cookbook

欢迎 Star 和 Fork!所有代码示例都可以在仓库中找到并运行。


🎯 本章导读

📌 本文目标:掌握 ASP.NET Core 中后台任务的实现方式,理解 IHostedService 和 BackgroundService 的使用场景,学会构建健壮的后台服务。

说到"并发编程",你可能会想到 async/await、Task、并行计算这些"前台"的技术。

但是,有一类任务,它们默默运行在后台,不需要用户等待,也不阻塞主线程,却承担着系统中最重要的职责:

  • 定时数据同步:每小时从外部 API 拉取最新数据
  • 消息队列消费:从 RabbitMQ、Kafka 中读取消息并处理
  • 邮件批量发送:从队列中取出待发送的邮件逐个发送
  • 日志批量写入:将内存中的日志批量写入数据库
  • 缓存预热:应用启动时预加载热点数据
  • 健康检查上报:定期向监控系统上报心跳

这些任务有一个共同特点:它们需要长期运行,独立于用户请求,并且与并发编程紧密相关

今天,我们就来聊聊 .NET 中后台任务的标准实现方式——IHostedService 和 BackgroundService

⚠️ 前置知识:本文涉及 async/await、Task、CancellationToken、Channel 等概念,建议先掌握前面章节的异步编程基础(第 03-06 章)和 Channel 的使用(第 12 章)。


0️⃣ 为什么后台任务与并发编程关系如此紧密?

0.1 一个真实的故事:电商系统的订单处理

假设你在开发一个电商系统,用户下单后需要做很多事情:

  1. 创建订单记录(快,50ms)
  2. 扣减库存(快,100ms)
  3. 发送确认邮件(慢,2 秒)
  4. 推送消息到物流系统(慢,1 秒)
  5. 更新用户积分(快,80ms)
  6. 记录操作日志(快,20ms)

如果你把这些操作都放在下单接口里同步执行,用户要等 3+ 秒 才能看到"下单成功"的提示,体验会非常差。

第一版优化:你学会了 async/await,把所有操作都改成异步:

[HttpPost("order")]
public async Task<IActionResult> CreateOrder(OrderRequest request)
{
	// 这些都是异步操作,看起来很快
	await _orderService.CreateAsync(request);
	await _inventoryService.DeductAsync(request.ProductId, request.Quantity);
	await _emailService.SendConfirmationAsync(request.Email);  // 还是要等 2 秒
	await _logisticsService.NotifyAsync(request);              // 还是要等 1 秒
	await _pointsService.UpdateAsync(request.UserId);
	await _logService.WriteAsync(request);

	return Ok("订单创建成功");
}

虽然用了 async/await,但 用户还是要等 3+ 秒!因为这些操作是 串行的,每个都在等待前一个完成。

第二版优化:你想起了 Task.WhenAll,可以并发执行:

[HttpPost("order")]
public async Task<IActionResult> CreateOrder(OrderRequest request)
{
	await _orderService.CreateAsync(request);
	await _inventoryService.DeductAsync(request.ProductId, request.Quantity);

	// 这些可以并发执行
	await Task.WhenAll(
		_emailService.SendConfirmationAsync(request.Email),
		_logisticsService.NotifyAsync(request),
		_pointsService.UpdateAsync(request.UserId),
		_logService.WriteAsync(request)
	);

	return Ok("订单创建成功");
}

这样好多了,用户只需要等 最慢的那个操作(发邮件 2 秒),而不是所有操作的总和。

但还有问题

  1. 如果发邮件失败了怎么办? 用户会看到"订单创建失败"吗?但订单其实已经创建了!
  2. 如果邮件服务很慢(10 秒)怎么办? 用户要等 10 秒?
  3. 如果同时有 1000 个订单怎么办? 会启动 1000 个发邮件任务,邮件服务器扛得住吗?

0.2 后台任务的登场

真正优雅的解决方案是:把非核心操作放到后台队列,立即返回给用户

[HttpPost("order")]
public async Task<IActionResult> CreateOrder(OrderRequest request)
{
	// 核心操作:必须成功
	await _orderService.CreateAsync(request);
	await _inventoryService.DeductAsync(request.ProductId, request.Quantity);

	// 非核心操作:放入后台队列
	await _backgroundQueue.QueueAsync(async ct =>
	{
		await _emailService.SendConfirmationAsync(request.Email, ct);
	});

	await _backgroundQueue.QueueAsync(async ct =>
	{
		await _logisticsService.NotifyAsync(request, ct);
	});

	// 立即返回
	return Ok("订单创建成功");
}

后台会有一个专门的服务不断从队列中取出任务并执行

public class QueuedBackgroundService : BackgroundService
{
	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		while (!stoppingToken.IsCancellationRequested)
		{
			var workItem = await _queue.DequeueAsync(stoppingToken);
			await workItem(stoppingToken); // 执行任务
		}
	}
}

这样就实现了:

用户体验好:只需等待核心操作(150ms),立即看到成功提示
系统健壮:邮件发送失败不影响订单创建
资源可控:可以限制后台任务的并发数(比如同时只发 10 封邮件)
可监控:可以统计队列长度、任务成功率等指标

0.3 后台任务的并发挑战

看起来很完美,但后台任务也面临着典型的并发编程挑战

  1. 如何优雅地启动和停止? 应用关闭时,要等待正在执行的任务完成吗?
  2. 如何处理异常? 任务失败了要重试吗?重试几次?
  3. 如何限制并发? 同时执行多少个任务?如何避免资源耗尽?
  4. 如何实现定时任务? 每隔 1 小时执行一次,怎么实现?
  5. 如何在异步上下文中访问作用域服务? BackgroundService 是单例,如何使用 Scoped 的 DbContext?

这些问题的答案,都藏在 IHostedServiceBackgroundService 的设计中。


1️⃣ IHostedService:后台任务的生命周期契约

1.1 什么是 IHostedService?

IHostedService 是 .NET 中定义后台任务生命周期的接口,它非常简单:

public interface IHostedService
{
	Task StartAsync(CancellationToken cancellationToken);
	Task StopAsync(CancellationToken cancellationToken);
}

只有两个方法

  • StartAsync:应用启动时调用(在 Kestrel 开始监听之前)
  • StopAsync:应用关闭时调用(在所有请求处理完之后)

1.2 IHostedService 的生命周期时序

让我们看看一个完整的应用启动和关闭过程中,IHostedService 的调用时机:

应用启动流程:
1. 构建 Host
2. 注册所有 IHostedService
3. 依次调用所有 IHostedService.StartAsync()  ← 这里!
4. 启动 Kestrel(开始接受 HTTP 请求)
5. 应用运行中...

应用关闭流程:
1. 收到 Ctrl+C 或 SIGTERM 信号
2. 停止接受新请求
3. 等待当前请求处理完(默认最多 5 秒)
4. 依次调用所有 IHostedService.StopAsync()  ← 这里!
5. 释放资源,进程退出

关键洞察

  • StartAsync 不应该阻塞!它的职责是"启动"后台任务,而不是"运行"任务本身
  • StopAsync 应该优雅地停止任务,比如设置取消令牌、等待任务完成

1.3 手写一个最简单的 IHostedService

让我们写一个每秒打印一次日志的后台服务:

public class SimpleHostedService : IHostedService, IDisposable
{
	private readonly ILogger<SimpleHostedService> _logger;
	private Timer? _timer;

	public SimpleHostedService(ILogger<SimpleHostedService> logger)
	{
		_logger = logger;
	}

	public Task StartAsync(CancellationToken cancellationToken)
	{
		_logger.LogInformation("SimpleHostedService 正在启动...");

		// 启动一个定时器,每秒触发一次
		_timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));

		return Task.CompletedTask; // 注意:立即返回,不阻塞
	}

	private void DoWork(object? state)
	{
		_logger.LogInformation("后台任务执行中:{Time}", DateTime.Now);
	}

	public Task StopAsync(CancellationToken cancellationToken)
	{
		_logger.LogInformation("SimpleHostedService 正在停止...");

		_timer?.Change(Timeout.Infinite, 0); // 停止定时器

		return Task.CompletedTask;
	}

	public void Dispose()
	{
		_timer?.Dispose();
	}
}

注册方式

builder.Services.AddHostedService<SimpleHostedService>();

运行效果

info: SimpleHostedService[0]
	  SimpleHostedService 正在启动...
info: SimpleHostedService[0]
	  后台任务执行中:2024-12-20 10:00:00
info: SimpleHostedService[0]
	  后台任务执行中:2024-12-20 10:00:01
info: SimpleHostedService[0]
	  后台任务执行中:2024-12-20 10:00:02
[Ctrl+C]
info: SimpleHostedService[0]
	  SimpleHostedService 正在停止...

1.4 IHostedService 的常见陷阱

陷阱 1:在 StartAsync 中阻塞

// ❌ 错误示范:StartAsync 阻塞了
public async Task StartAsync(CancellationToken cancellationToken)
{
	while (!cancellationToken.IsCancellationRequested)
	{
		await Task.Delay(1000, cancellationToken); // 永远不返回!
		DoWork();
	}
}

后果:应用永远无法启动完成,Kestrel 不会开始监听!

陷阱 2:忽略 CancellationToken

// ❌ 错误示范:StopAsync 中没有停止任务
public Task StopAsync(CancellationToken cancellationToken)
{
	_logger.LogInformation("正在停止...");
	return Task.CompletedTask; // 但任务还在后台运行!
}

后果:应用关闭时,后台任务还在运行,可能导致数据损坏。

1.5 为什么需要 BackgroundService?

虽然 IHostedService 很简单,但大多数后台任务都有一个长期运行的循环

while (!cancellationToken.IsCancellationRequested)
{
	// 执行任务
	await Task.Delay(interval, cancellationToken);
}

每次都手写这个模式很繁琐,而且容易出错(比如忘记检查 cancellationToken)。

BackgroundService 就是为了简化这个模式而设计的


2️⃣ BackgroundService:长期运行任务的最佳基类

2.1 BackgroundService 的实现原理

BackgroundService 是一个抽象类,实现了 IHostedService 接口:

public abstract class BackgroundService : IHostedService
{
	private Task? _executeTask;
	private CancellationTokenSource? _stoppingCts;

	public virtual Task StartAsync(CancellationToken cancellationToken)
	{
		_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

		// 启动长期运行的任务(不等待它完成)
		_executeTask = ExecuteAsync(_stoppingCts.Token);

		return Task.CompletedTask; // 立即返回
	}

	protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

	public virtual async Task StopAsync(CancellationToken cancellationToken)
	{
		if (_executeTask == null) return;

		try
		{
			_stoppingCts?.Cancel(); // 发出停止信号
		}
		finally
		{
			// 等待任务完成(或超时)
			await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken));
		}
	}
}

核心设计

  1. StartAsync 启动 ExecuteAsync,但不等待它完成
  2. ExecuteAsync 是你需要实现的方法,里面写长期运行的逻辑
  3. StopAsync 会发出取消信号,并等待 ExecuteAsync 完成

2.2 使用 PeriodicTimer 实现定时任务(.NET 6+)

在 .NET 6 之前,定时任务通常用 TimerTask.Delay 循环实现。但它们都有缺点:

  • Timer:回调是同步的,不支持异步
  • Task.Delay 循环:如果任务执行时间超过间隔,会导致任务堆积

.NET 6 引入了 PeriodicTimer,专为异步定时任务设计:

public class TimedBackgroundService : BackgroundService
{
	private readonly ILogger<TimedBackgroundService> _logger;
	private readonly PeriodicTimer _timer = new(TimeSpan.FromSeconds(5));

	public TimedBackgroundService(ILogger<TimedBackgroundService> logger)
	{
		_logger = logger;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		_logger.LogInformation("定时后台服务已启动");

		try
		{
			// PeriodicTimer 的 WaitForNextTickAsync 会等待下一个时间点
			while (await _timer.WaitForNextTickAsync(stoppingToken))
			{
				await DoWorkAsync(stoppingToken);
			}
		}
		catch (OperationCanceledException)
		{
			_logger.LogInformation("定时后台服务已停止");
		}
	}

	private async Task DoWorkAsync(CancellationToken ct)
	{
		_logger.LogInformation("执行定时任务:{Time}", DateTime.Now);

		// 模拟耗时操作
		await Task.Delay(2000, ct);

		_logger.LogInformation("任务完成:{Time}", DateTime.Now);
	}

	public override void Dispose()
	{
		_timer.Dispose();
		base.Dispose();
	}
}

PeriodicTimer 的优势

自动跳过重叠:如果任务执行时间 > 间隔,下一次触发会延迟,不会堆积
异步友好WaitForNextTickAsync 返回 ValueTask<bool>
支持取消:传入 CancellationToken 自动响应停止信号

对比传统 Task.Delay 循环

// ❌ 传统方式:可能导致任务堆积
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (!stoppingToken.IsCancellationRequested)
	{
		await DoWorkAsync(stoppingToken); // 如果这个耗时 3 秒
		await Task.Delay(1000, stoppingToken); // 下一次还是会在 1 秒后启动
	}
}

// ✅ PeriodicTimer:自动处理重叠
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (await _timer.WaitForNextTickAsync(stoppingToken))
	{
		await DoWorkAsync(stoppingToken); // 耗时 3 秒也没关系
		// PeriodicTimer 会等任务完成后再计时
	}
}

3️⃣ 实战模式 1:后台队列处理

3.1 场景:异步发送邮件

回到文章开头的场景:订单创建后需要发送确认邮件,但不能阻塞用户请求。

设计思路

  1. 提供一个 IBackgroundTaskQueue 接口,用于入队任务
  2. 实现一个基于 Channel 的队列
  3. 创建一个 QueuedBackgroundService 不断从队列中取任务执行

3.2 实现:基于 Channel 的后台队列

队列接口

public interface IBackgroundTaskQueue
{
	/// <summary>
	/// 将任务加入队列
	/// </summary>
	ValueTask QueueAsync(Func<CancellationToken, ValueTask> workItem);

	/// <summary>
	/// 从队列中取出任务
	/// </summary>
	ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(CancellationToken cancellationToken);
}

队列实现(使用 Channel):

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
	private readonly Channel<Func<CancellationToken, ValueTask>> _queue;

	public BackgroundTaskQueue(int capacity = 100)
	{
		var options = new BoundedChannelOptions(capacity)
		{
			FullMode = BoundedChannelFullMode.Wait // 队列满时等待
		};
		_queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
	}

	public async ValueTask QueueAsync(Func<CancellationToken, ValueTask> workItem)
	{
		ArgumentNullException.ThrowIfNull(workItem);

		await _queue.Writer.WriteAsync(workItem);
	}

	public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
		CancellationToken cancellationToken)
	{
		var workItem = await _queue.Reader.ReadAsync(cancellationToken);
		return workItem;
	}
}

后台服务

public class QueuedBackgroundService : BackgroundService
{
	private readonly IBackgroundTaskQueue _taskQueue;
	private readonly ILogger<QueuedBackgroundService> _logger;

	public QueuedBackgroundService(
		IBackgroundTaskQueue taskQueue,
		ILogger<QueuedBackgroundService> logger)
	{
		_taskQueue = taskQueue;
		_logger = logger;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		_logger.LogInformation("队列处理服务已启动");

		while (!stoppingToken.IsCancellationRequested)
		{
			try
			{
				// 从队列中取出任务
				var workItem = await _taskQueue.DequeueAsync(stoppingToken);

				// 执行任务
				await workItem(stoppingToken);

				_logger.LogInformation("队列任务执行成功");
			}
			catch (OperationCanceledException)
			{
				// 应用正在关闭
				break;
			}
			catch (Exception ex)
			{
				_logger.LogError(ex, "执行队列任务时发生错误");
			}
		}

		_logger.LogInformation("队列处理服务已停止");
	}
}

注册服务

builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
builder.Services.AddHostedService<QueuedBackgroundService>();

使用示例

[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
	private readonly IBackgroundTaskQueue _queue;
	private readonly ILogger<OrderController> _logger;

	public OrderController(
		IBackgroundTaskQueue queue,
		ILogger<OrderController> logger)
	{
		_queue = queue;
		_logger = logger;
	}

	[HttpPost]
	public async Task<IActionResult> CreateOrder(OrderRequest request)
	{
		// 核心业务逻辑(同步)
		_logger.LogInformation("创建订单:{OrderId}", request.OrderId);

		// 发送邮件(后台异步)
		await _queue.QueueAsync(async ct =>
		{
			_logger.LogInformation("开始发送邮件:{Email}", request.Email);
			await Task.Delay(2000, ct); // 模拟发送邮件
			_logger.LogInformation("邮件发送成功:{Email}", request.Email);
		});

		return Ok(new { Message = "订单创建成功", OrderId = request.OrderId });
	}
}

public record OrderRequest(string OrderId, string Email);

运行效果

[请求] POST /api/order
info: OrderController[0]
	  创建订单:ORDER-001
info: OrderController[0]
	  返回响应(耗时 50ms)

[后台队列]
info: QueuedBackgroundService[0]
	  开始发送邮件:user@example.com
[2 秒后]
info: QueuedBackgroundService[0]
	  邮件发送成功:user@example.com
info: QueuedBackgroundService[0]
	  队列任务执行成功

4️⃣ 实战模式 2:定时数据同步

4.1 场景:每小时同步外部 API 数据

假设你的系统需要每小时从外部 API 拉取商品价格更新:

public class DataSyncService : BackgroundService
{
	private readonly ILogger<DataSyncService> _logger;
	private readonly IServiceScopeFactory _scopeFactory;
	private readonly PeriodicTimer _timer = new(TimeSpan.FromHours(1));

	public DataSyncService(
		ILogger<DataSyncService> logger,
		IServiceScopeFactory scopeFactory)
	{
		_logger = logger;
		_scopeFactory = scopeFactory;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		_logger.LogInformation("数据同步服务已启动,每小时执行一次");

		// 启动时立即执行一次
		await SyncDataAsync(stoppingToken);

		// 之后每小时执行一次
		while (await _timer.WaitForNextTickAsync(stoppingToken))
		{
			await SyncDataAsync(stoppingToken);
		}
	}

	private async Task SyncDataAsync(CancellationToken ct)
	{
		_logger.LogInformation("开始同步数据:{Time}", DateTime.Now);

		try
		{
			// ⚠️ 关键:使用 Scope 创建作用域服务
			using var scope = _scopeFactory.CreateScope();
			var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
			var httpClient = scope.ServiceProvider.GetRequiredService<HttpClient>();

			// 从外部 API 获取数据
			var products = await httpClient.GetFromJsonAsync<List<Product>>(
				"https://api.example.com/products", ct);

			if (products != null)
			{
				// 更新数据库
				foreach (var product in products)
				{
					var existing = await dbContext.Products
						.FirstOrDefaultAsync(p => p.Id == product.Id, ct);

					if (existing != null)
					{
						existing.Price = product.Price;
						existing.UpdatedAt = DateTime.UtcNow;
					}
					else
					{
						dbContext.Products.Add(product);
					}
				}

				await dbContext.SaveChangesAsync(ct);
				_logger.LogInformation("数据同步成功,更新了 {Count} 个商品", products.Count);
			}
		}
		catch (Exception ex)
		{
			_logger.LogError(ex, "数据同步失败");
		}
	}

	public override void Dispose()
	{
		_timer.Dispose();
		base.Dispose();
	}
}

4.2 关键点:作用域服务的使用

问题:BackgroundService 是单例(Singleton),但 DbContext 是作用域(Scoped),如何在单例中使用作用域服务?

答案:使用 IServiceScopeFactory 手动创建作用域:

using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

为什么需要 Scope?

  • 每次同步数据都是一个独立的"工作单元",应该有独立的 DbContext 实例
  • Scope 结束时会自动释放资源(Dispose DbContext)
  • 避免 DbContext 被多个并发任务共享(DbContext 不是线程安全的)

5️⃣ 实战模式 3:带重试的邮件发送服务

5.1 场景:批量发送邮件,失败自动重试

这是一个结合了"队列处理"和"重试机制"的完整示例:

public class EmailSenderService : BackgroundService
{
	private readonly IBackgroundTaskQueue _queue;
	private readonly ILogger<EmailSenderService> _logger;
	private readonly IServiceScopeFactory _scopeFactory;

	public EmailSenderService(
		IBackgroundTaskQueue queue,
		ILogger<EmailSenderService> logger,
		IServiceScopeFactory scopeFactory)
	{
		_queue = queue;
		_logger = logger;
		_scopeFactory = scopeFactory;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		_logger.LogInformation("邮件发送服务已启动");

		while (!stoppingToken.IsCancellationRequested)
		{
			try
			{
				var workItem = await _queue.DequeueAsync(stoppingToken);
				await ExecuteWithRetryAsync(workItem, stoppingToken);
			}
			catch (OperationCanceledException)
			{
				break;
			}
		}
	}

	private async Task ExecuteWithRetryAsync(
		Func<CancellationToken, ValueTask> workItem,
		CancellationToken ct)
	{
		const int maxRetries = 3;
		var retryDelays = new[] { 1000, 5000, 15000 }; // 1s, 5s, 15s

		for (int attempt = 0; attempt < maxRetries; attempt++)
		{
			try
			{
				await workItem(ct);
				_logger.LogInformation("邮件发送成功");
				return; // 成功,退出
			}
			catch (Exception ex) when (attempt < maxRetries - 1)
			{
				_logger.LogWarning(ex,
					"邮件发送失败(尝试 {Attempt}/{Max}),{Delay}ms 后重试",
					attempt + 1, maxRetries, retryDelays[attempt]);

				await Task.Delay(retryDelays[attempt], ct);
			}
			catch (Exception ex)
			{
				_logger.LogError(ex, "邮件发送失败,已达最大重试次数");
				// 可以记录到数据库或死信队列
			}
		}
	}
}

重试策略

  1. 第一次失败:等待 1 秒后重试
  2. 第二次失败:等待 5 秒后重试
  3. 第三次失败:等待 15 秒后重试
  4. 仍然失败:记录错误日志,放弃

改进方向

  • 使用 Polly 库实现更复杂的重试策略(指数退避、抖动)
  • 失败的任务存入死信队列(Dead Letter Queue)
  • 记录失败任务到数据库,支持手动重试

6️⃣ 生命周期管理与优雅停止

6.1 优雅停止的最佳实践

当应用收到停止信号(Ctrl+C 或 SIGTERM)时,应该:

  1. 停止接受新任务
  2. 完成正在执行的任务(设置超时)
  3. 释放资源

示例:优雅停止的队列处理服务

public class GracefulQueuedService : BackgroundService
{
	private readonly IBackgroundTaskQueue _queue;
	private readonly ILogger<GracefulQueuedService> _logger;

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		_logger.LogInformation("服务已启动");

		while (!stoppingToken.IsCancellationRequested)
		{
			try
			{
				var workItem = await _queue.DequeueAsync(stoppingToken);

				// 检查是否正在停止
				if (stoppingToken.IsCancellationRequested)
				{
					_logger.LogWarning("收到停止信号,任务将不会执行");
					break;
				}

				await workItem(stoppingToken);
			}
			catch (OperationCanceledException)
			{
				break;
			}
		}

		_logger.LogInformation("服务已停止");
	}

	public override async Task StopAsync(CancellationToken cancellationToken)
	{
		_logger.LogInformation("正在优雅停止服务...");

		// 调用基类的 StopAsync,会设置 stoppingToken 为 Cancelled
		await base.StopAsync(cancellationToken);

		_logger.LogInformation("服务已完全停止");
	}
}

6.2 配置停止超时

默认情况下,ASP.NET Core 会等待 5 秒 让 HostedService 停止。如果超时,会强制终止。

修改超时配置

builder.Host.ConfigureHostOptions(options =>
{
	options.ShutdownTimeout = TimeSpan.FromSeconds(30); // 等待 30 秒
});

7️⃣ 常见陷阱与最佳实践

7.1 陷阱 1:在构造函数中访问作用域服务

// ❌ 错误:BackgroundService 是单例,不能在构造函数中注入 Scoped 服务
public class BadService : BackgroundService
{
	private readonly AppDbContext _dbContext; // ❌ Scoped 服务

	public BadService(AppDbContext dbContext)
	{
		_dbContext = dbContext; // ❌ 错误!
	}
}

// ✅ 正确:使用 IServiceScopeFactory
public class GoodService : BackgroundService
{
	private readonly IServiceScopeFactory _scopeFactory;

	public GoodService(IServiceScopeFactory scopeFactory)
	{
		_scopeFactory = scopeFactory;
	}

	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		using var scope = _scopeFactory.CreateScope();
		var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
		// 使用 dbContext...
	}
}

7.2 陷阱 2:忘记响应 CancellationToken

// ❌ 错误:长时间运行的任务不检查取消
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (true) // ❌ 永远不停止!
	{
		await DoWorkAsync();
		await Task.Delay(1000); // ❌ 没有传递 stoppingToken
	}
}

// ✅ 正确:检查取消并传递令牌
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (!stoppingToken.IsCancellationRequested)
	{
		await DoWorkAsync(stoppingToken);
		await Task.Delay(1000, stoppingToken);
	}
}

7.3 陷阱 3:异常未处理导致服务停止

// ❌ 错误:异常未捕获,服务会停止
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (!stoppingToken.IsCancellationRequested)
	{
		await DoWorkAsync(stoppingToken); // 如果抛异常,整个服务停止
		await Task.Delay(1000, stoppingToken);
	}
}

// ✅ 正确:捕获异常并记录
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
	while (!stoppingToken.IsCancellationRequested)
	{
		try
		{
			await DoWorkAsync(stoppingToken);
		}
		catch (Exception ex)
		{
			_logger.LogError(ex, "任务执行失败");
		}

		await Task.Delay(1000, stoppingToken);
	}
}

7.4 最佳实践清单

始终响应 CancellationToken:所有异步操作都传递 stoppingToken
使用 IServiceScopeFactory:在单例服务中访问作用域服务
捕获并记录异常:防止单个任务失败导致整个服务停止
使用 PeriodicTimer:替代 TimerTask.Delay 循环
使用 Channel 实现队列:替代 ConcurrentQueueBlockingCollection
配置合理的超时:根据任务性质调整 ShutdownTimeout
监控队列长度:避免队列无限增长导致内存溢出
使用结构化日志:记录任务执行状态、耗时、错误信息


8️⃣ 与 Worker Service 的关系

8.1 什么是 Worker Service?

Worker Service 是 .NET 提供的一种项目模板,专门用于构建长期运行的后台服务

dotnet new worker -n MyWorkerService

生成的项目结构:

MyWorkerService/
├── Program.cs
├── Worker.cs  ← 继承自 BackgroundService
└── appsettings.json

Worker.cs 的默认内容

public class Worker : BackgroundService
{
	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		while (!stoppingToken.IsCancellationRequested)
		{
			_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
			await Task.Delay(1000, stoppingToken);
		}
	}
}

8.2 Worker Service vs ASP.NET Core HostedService

特性 Worker Service ASP.NET Core HostedService
用途 独立的后台服务(无 HTTP) 作为 Web 应用的一部分
部署方式 Windows 服务、Linux 守护进程 随 Web 应用部署
HTTP 支持 ❌ 无 ✅ 有(Kestrel)
适用场景 纯后台任务(消息消费、数据同步) Web 应用 + 后台任务

何时选择 Worker Service?

  • 后台任务不需要 HTTP 接口
  • 需要部署为系统服务(Windows 服务、systemd)
  • 独立于 Web 应用运行

何时选择 ASP.NET Core HostedService?

  • 后台任务是 Web 应用的一部分(如订单处理、邮件发送)
  • 需要通过 HTTP API 触发或监控后台任务
  • 希望复用 ASP.NET Core 的依赖注入、配置、日志等基础设施

9️⃣ 高级话题:集成 Quartz.NET 和 Hangfire

9.1 为什么需要调度框架?

BackgroundService 适合简单的定时任务和队列处理,但如果你需要:

  • Cron 表达式:"每天凌晨 2 点执行"、"每周一上午 9 点执行"
  • 分布式调度:多个实例中只有一个执行任务
  • 任务持久化:应用重启后任务不丢失
  • Web UI 管理:可视化地查看和管理任务

那么你需要专业的调度框架:Quartz.NETHangfire

9.2 Quartz.NET 简介

安装

dotnet add package Quartz
dotnet add package Quartz.Extensions.Hosting

示例:每天凌晨 2 点执行数据清理

// 1. 定义任务
public class DataCleanupJob : IJob
{
	private readonly ILogger<DataCleanupJob> _logger;

	public DataCleanupJob(ILogger<DataCleanupJob> logger)
	{
		_logger = logger;
	}

	public async Task Execute(IJobExecutionContext context)
	{
		_logger.LogInformation("开始清理过期数据:{Time}", DateTime.Now);

		// 清理逻辑...
		await Task.Delay(1000);

		_logger.LogInformation("数据清理完成");
	}
}

// 2. 注册到 DI
builder.Services.AddQuartz(q =>
{
	var jobKey = new JobKey("DataCleanupJob");

	q.AddJob<DataCleanupJob>(opts => opts.WithIdentity(jobKey));

	q.AddTrigger(opts => opts
		.ForJob(jobKey)
		.WithIdentity("DataCleanupJob-trigger")
		.WithCronSchedule("0 0 2 * * ?")); // 每天凌晨 2 点
});

builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

9.3 Hangfire 简介

安装

dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer

示例:延迟任务和周期任务

// 1. 注册 Hangfire
builder.Services.AddHangfire(config => config
	.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb")));

builder.Services.AddHangfireServer();

var app = builder.Build();

// 2. 启用 Dashboard(Web UI)
app.MapHangfireDashboard();

// 3. 创建任务
// 立即执行
BackgroundJob.Enqueue(() => Console.WriteLine("立即执行的任务"));

// 延迟执行
BackgroundJob.Schedule(() => Console.WriteLine("5 分钟后执行"), TimeSpan.FromMinutes(5));

// 周期任务
RecurringJob.AddOrUpdate("daily-cleanup", () => CleanupData(), Cron.Daily(2)); // 每天凌晨 2 点

Hangfire Dashboard

访问 https://localhost:5001/hangfire 可以看到:

  • 所有任务的执行状态
  • 失败任务的详细信息
  • 手动触发任务
  • 查看服务器状态

🎯 总结

核心要点回顾

  1. 后台任务与并发编程密不可分

    • 后台任务需要处理异步操作、资源限制、任务调度等并发问题
    • 良好的系统几乎都需要后台任务来处理非核心、耗时的操作
  2. IHostedService 是生命周期契约

    • StartAsync:启动任务(不阻塞)
    • StopAsync:优雅停止(等待任务完成)
  3. BackgroundService 是最佳基类

    • 简化了长期运行任务的实现
    • 自动处理启动、停止、取消
  4. 常见模式

    • 定时任务:使用 PeriodicTimer
    • 队列处理:使用 Channel<T>
    • 数据同步:使用 IServiceScopeFactory 创建作用域
  5. 最佳实践

    • 始终响应 CancellationToken
    • 使用 IServiceScopeFactory 访问作用域服务
    • 捕获并记录异常
    • 配置合理的停止超时
  6. 高级方案

    • 简单场景:BackgroundService
    • 复杂调度:Quartz.NET
    • 分布式任务:Hangfire

决策树:选择合适的方案

需要后台任务?
├─ 是否需要 Cron 表达式?
│  ├─ 是 → 使用 Quartz.NET
│  └─ 否 → 继续
├─ 是否需要分布式调度?
│  ├─ 是 → 使用 Hangfire
│  └─ 否 → 继续
├─ 是否需要 Web UI 管理?
│  ├─ 是 → 使用 Hangfire
│  └─ 否 → 继续
└─ 简单定时任务或队列处理
   └─ 使用 BackgroundService + PeriodicTimer/Channel

posted @ 2026-06-21 22:12  呆萌哈士奇  阅读(47)  评论(0)    收藏  举报