在 TPL 数据流中动态订阅/取消订阅

时间:2021-05-19 12:41:42

标签: c# concurrency task-parallel-library tpl-dataflow

我有一个消息流,根据一些标准,我希望每个消费者都能够并行处理其中的一些。每个消费者都应该能够动态订阅和取消订阅。 enter image description here

我有以下输入数据约束:

  • 每秒大约 500 条消息
  • 大约 15000 名消费者
  • 大约 500 个类别
  • 在大多数情况下,每个消费者订阅了 1-3 个类别。

到目前为止,这是我所拥有的:

public class Test
{
    static void Main()
    {
        var consumer1 = new Consumer("Consumer1");
        consumer1.SubscribeForCategory(1);
        consumer1.SubscribeForCategory(2);

        var consumer2 = new Consumer("Consumer2");
        consumer2.SubscribeForCategory(2);
        consumer2.SubscribeForCategory(3);
        consumer2.SubscribeForCategory(4);

        var consumer3 = new Consumer("Consumer3");
        consumer3.SubscribeForCategory(3);
        consumer3.SubscribeForCategory(4);

        var consumers = new[] {consumer1, consumer2, consumer3};
        var publisher = new Publisher(consumers);

        var message1 = new Message(1, "message1 test");
        var message2 = new Message(2, "message2");
        var message3 = new Message(1, "message3");
        var message4 = new Message(3, "message4 test");
        var message5 = new Message(4, "message5");
        var message6 = new Message(3, "message6");

        var messages = new[] {message1, message2, message3, message4, message5, message6};

        foreach (var message in messages)
        {
            publisher.Publish(message);
        }

        Console.ReadLine();
    }
}

public class Message
{
    public Message(int categoryId, string data)
    {
        CategoryId = categoryId;
        Data = data;
    }

    public int CategoryId { get; }

    public string Data { get; }
}

public class Publisher
{
    private readonly IEnumerable<Consumer> _consumers;

    public Publisher(IEnumerable<Consumer> consumers)
    {
        _consumers = consumers;
    }

    public void Publish(Message message)
    {
        IEnumerable<Consumer> consumers = _consumers.Where(c => c.CategoryIds.Contains(message.CategoryId));
        foreach (Consumer consumer in consumers)
        {
            consumer.AddMessage(message);
        }
    }
}

public class Consumer
{
    private readonly HashSet<int> _categoryIds;
    private readonly ActionBlock<Message> _queue;

    public Consumer(string name)
    {
        Name = name;
        _categoryIds = new HashSet<int>();

        _queue = new ActionBlock<Message>(async m => { await Foo(m); }, 
                                          new ExecutionDataflowBlockOptions 
                                          {
                                              MaxDegreeOfParallelism = 1, 
                                              SingleProducerConstrained = true
                                          });
    }

    public string Name { get; }

    public IReadOnlyCollection<int> CategoryIds => _categoryIds;

    public void AddMessage(Message message)
    {
        bool accepted = _queue.Post(message);
        if (!accepted)
        {
            Console.WriteLine("Message has been rejected!");
        }
    }

    public void SubscribeForCategory(int categoryId)
    {
        _categoryIds.Add(categoryId);
    }

    private async Task Foo(Message message)
    {
        // process message
        await Task.Delay(10);

        if (message.Data.Contains("test"))
        {
            _categoryIds.Remove(message.CategoryId);
        }

        Console.WriteLine($"[{Name}] - category id: [{message.CategoryId}] data: [{message.Data}]");
    }
}

不幸的是,该解决方案存在几个问题:

  1. 在消费者处理每条消息时,有可能取消订阅某些已添加到ActionBlock 输入队列的消息。
  2. 在 Publisher.cs 中,我正在迭代每个帐户类别集合,稍后在 Account Foo 方法中,有机会删除某些类别,这将导致以下异常:System.InvalidOperationException: Collection was modified;枚举操作可能无法执行。
  3. 另外,我不太确定将“调度逻辑”加入到publisher.Publish() 中是否是一个好主意

一种可能的解决方案是将所有消息转发给每个消费者(每个消费者都应该决定是否应该处理它),但我担心这会慢得多。

我知道基于 Actor 模型的框架,例如 Akka.Net 和 Microsoft Orleans,但我希望所有这些都在进程中发生(当然,如果可以实现的话)。

有没有人有更优雅的解决方案?您对我如何改进当前方法有什么建议吗?

1 个答案:

答案 0 :(得分:1)

我认为您的模型中缺少实体 Category,添加它不仅会在概念上而且会在性能方面改进您的模型。每个类别都可以包含订阅该类别的消费者列表,因此只需向订阅的消费者发送消息就变得很简单。

为了解决线程安全问题,我的建议是使用 immutable collections 而不是可变的 HashSet<T>List<T>。不可变集合的优势在于它们可以使用低锁技术(ImmutableInterlocked.Update 方法)安全和原子地更新,并且可以随时提供其内容的快照,不受未来修改的影响。如果您问如何改变不可变集合,答案是您没有改变它,而是用不同的不可变集合替换引用。这些结构的实现方式允许其内部零碎的高度可重用性。例如,在已经包含 1,000,000 个项目的 ImmutableHashSet<T> 中添加一个项目,不需要分配包含所有旧项目和新项目的新内存块。只会分配少量的小对象(内部二叉树中的节点)。

这种便利是有代价的:大多数对不可变集合的操作比对可变集合的相同操作至少慢 10 倍。从总体上看,这种开销很可能可以忽略不计,但您可能希望自己分析和衡量它,并判断它是否有影响。

Category 类:

public class Category
{
    private ImmutableHashSet<Consumer> _consumers;

    public int Id { get; }
    public ImmutableHashSet<Consumer> Consumers => Volatile.Read(ref _consumers);

    public Category(int id)
    {
        this.Id = id;
        _consumers = ImmutableHashSet.Create<Consumer>();
    }

    public void SubscribeConsumer(Consumer consumer) =>
        ImmutableInterlocked.Update(ref _consumers, col => col.Add(consumer));

    public void UnsubscribeConsumer(Consumer consumer) =>
        ImmutableInterlocked.Update(ref _consumers, col => col.Remove(consumer));
}

请注意 Volatile.Read,它确保所有访问 _consumers 属性的线程都可以立即看到存储在 Consumers 字段中的最新引用。

Consumer 类:

public class Consumer
{
    private readonly ActionBlock<Message> _block;
    private IImmutableList<Category> _categories;

    public string Name { get; }
    public IImmutableList<Category> Categories => Volatile.Read(ref _categories);

    public Consumer(string name)
    {
        this.Name = name;
        _categories = ImmutableArray.Create<Category>();
        _block = new ActionBlock<Message>(async message =>
        {
            if (!Categories.Any(cat => cat.Id == message.CategoryId)) return;
            // Process message...
        });
    }

    public void SendMessage(Message message)
    {
        bool accepted = _block.Post(message);
        Debug.Assert(accepted);
    }

    public void SubscribeForCategory(Category category)
    {
        ImmutableInterlocked.Update(ref _categories, col => col.Add(category));
        category.SubscribeConsumer(this);
    }

    public void UnsubscribeForCategory(Category category)
    {
        ImmutableInterlocked.Update(ref _categories, col => col.Remove(category));
        category.UnsubscribeConsumer(this);
    }
}

请注意,SubscribeForCategory 方法还负责添加反向关系(类别 -> 消费者)。在上面的实现中,这两个关系没有相互原子地添加,这意味着观察者可以看到订阅了一个类别的消费者,以及没有订阅消费者的类别。根据您的描述,您的应用中似乎不存在这样的观察者,因此这种不一致可能没有太大关系。

Publisher 类需要保存类别列表,而不是消费者:

public class Publisher
{
    private readonly Dictionary<int, Category> _categories;

    public Publisher(IEnumerable<Category> categories)
    {
        _categories = categories.ToDictionary(cat => cat.Id);
    }

    public void Publish(Message message)
    {
        var category = _categories[message.CategoryId];
        foreach (Consumer consumer in category.Consumers)
            consumer.SendMessage(message);
    }
}

注意 Publish 方法是多么简单。