如何实现C#统计服务的线程安全版本?
Great question! Making this service thread-safe without sacrificing performance is totally achievable—let’s break down the key changes and optimizations you need:
1. Replace Dictionary with ConcurrentDictionary for Company-Level Storage
First, swap out your regular Dictionary<string, CompanyStats> with ConcurrentDictionary<string, CompanyStats>. This gives you thread-safe add/retrieve operations out of the box, but we still need to handle the "thundering herd" problem where multiple threads might try to load the same company's data at once.
To avoid redundant database calls when loading a company for the first time, add a secondary lock dictionary to gate access per company:
private readonly ConcurrentDictionary<string, CompanyStats> _statsByCompany = new(); private readonly ConcurrentDictionary<string, object> _companyLocks = new();
2. Refactor CompanyStats and UserStats for Thread Safety
Your List<UserStats> isn't thread-safe, so replace it with another ConcurrentDictionary (keyed by a composite of user ID and hour for fast lookups). Also, wrap non-atomic operations (like incrementing requests and updating the timestamp) in locks to ensure consistency:
private class CompanyStats { public ConcurrentDictionary<string, UserStats> UserStats { get; } public CompanyStats(IEnumerable<UserStats> userStats) { UserStats = new ConcurrentDictionary<string, UserStats>( userStats.Select(us => new KeyValuePair<string, UserStats>(GetUserHourKey(us.UserId, us.Hour), us)) ); } public static string GetUserHourKey(string userId, DateTime hour) => $"{userId}_{hour:yyyy-MM-dd HH:00}"; } private class UserStats { public string UserId { get; } public DateTime Hour { get; } private int _requests; public int Requests => Volatile.Read(ref _requests); public DateTime Updated { get; private set; } private readonly object _syncRoot = new(); public UserStats(string userId, int requests, DateTime hour) { UserId = userId; _requests = requests; Hour = hour; Updated = DateTime.MinValue; } public void IncrementAndUpdateTimestamp() { lock (_syncRoot) { _requests++; Updated = DateTime.UtcNow; } } public void ResetUpdatedTimestamp() { lock (_syncRoot) { Updated = DateTime.MinValue; } } }
3. Rewrite Increment to Be Thread-Safe
Update the Increment method to use the concurrent collections and per-company locks to prevent duplicate database loads. We'll use GetOrAdd to safely retrieve or create company/user stats:
public void Increment(string companyId, string userId) { var utcNow = DateTime.UtcNow; var currentHour = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, utcNow.Hour, 0, 0, DateTimeKind.Utc); // Get or create a lock object for the company to prevent duplicate DB loads var companyLock = _companyLocks.GetOrAdd(companyId, _ => new object()); lock (companyLock) { // Safely get or load the company's stats var companyStats = _statsByCompany.GetOrAdd(companyId, id => { var dbStats = database.GetAllFromThisMonth(id); return new CompanyStats(dbStats); }); // Get or create the user's stats for the current hour var userHourKey = CompanyStats.GetUserHourKey(userId, currentHour); var userStats = companyStats.UserStats.GetOrAdd(userHourKey, _ => new UserStats(userId, 1, currentHour)); userStats.IncrementAndUpdateTimestamp(); } }
4. Make Tick Thread-Safe
The Tick method needs to safely iterate over stats and update the database. Use snapshots of collections to avoid "collection modified" exceptions, and double-check the Updated timestamp inside the user stats lock to prevent race conditions:
public void Tick(object state) { var utcNow = DateTime.UtcNow; var currentHour = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, utcNow.Hour, 0, 0, DateTimeKind.Utc); // Iterate over a snapshot of company keys to avoid collection modification issues foreach (var companyId in _statsByCompany.Keys.ToList()) { if (!_statsByCompany.TryGetValue(companyId, out var companyStats)) continue; // Get a snapshot of user stats that need updating var usersToUpdate = companyStats.UserStats.Values .Where(us => us.Updated > lastTick) .ToList(); foreach (var userStats in usersToUpdate) { lock (userStats._syncRoot) { // Double-check in case another thread reset the timestamp after we queried if (userStats.Updated <= lastTick) continue; database.Save(GenerateSomeEntity(userStats.Requests)); userStats.ResetUpdatedTimestamp(); } } } // Clear cache if we've crossed a month boundary if (lastTick != DateTime.MinValue && lastTick.Month != utcNow.Month) { _statsByCompany.Clear(); _companyLocks.Clear(); // Clean up unused locks too } lastTick = utcNow; }
Key Performance & Safety Notes
- No Global Locks: We use per-company and per-user-hour locks, so contention is minimized—only threads accessing the same company/user/hour will block each other.
- Idempotent DB Loads: Even if multiple threads hit the company lock at the same time, the
GetOrAddensures only one instance ofCompanyStatsis stored. The per-company lock eliminates redundant database calls entirely. - Atomic Operations: Locking around
IncrementAndUpdateTimestampensures we don't split the request count increment and timestamp update across threads. - Snapshot Iteration: Using
ToList()on collection keys/values prevents exceptions when the collection is modified during iteration.
内容的提问来源于stack exchange,提问作者ThomasArdal

