3

I'm working on a simple irc chat bot (specifically for twitch.tv streams), and I'm using a List to keep a list of all the users in the channel. When someone leaves or joins, I add or remove them from the list. Then I have a thread that runs every minute that checks if the stream is online, and if it is, it hands out "currency" to all the people in my user list.

I'm sure you can already see where my problem is. If someone leaves or joins while my program is looping through the users in my list, then I get a Collection Modified exception. Currently, as a workaround, I just make a temp list and copy the real list into it, then loop through the temp list instead, but I was just curious if there was a "better" way to do it?

Quick psuedocode:

private List<string> users = new List<string>();

private void IrcInitialize(){
    //connect to irc stuff
    //blah
    //blah
    //blah
    Thread workThread = new Thread(new ThreadStart(doWork());
    workThread.Start();
}

private void ircListener(){
    parseIRCMessage(StreamReader.ReadLine());
}

private void parseIRCMessage(msg){
    if (msgType == "JOIN"){
        users.Add(user);
    }
    else if (msgType == "PART"){
        users.Remove(user);
    }
}

private void doWork(){
    while (true) {
        if (streamOnline() && handOutTime()){
            handOutCurrency();
        }
        Thread.Sleep(60000);
    }
}

private void handOutCurrency(){
    List<string> temp = users; //This is what I'm currently doing
    foreach (String user in temp) {
        database.AddCurrency(user, 1);
    }
}

Any other suggestions?

Keirathi
  • 397
  • 1
  • 5
  • 18
  • You could require reads and edits to the list to acquire a `lock` first, so while there is a reader it cannot be edited, and while there is an editor it can't be read. – Patashu Jun 06 '13 at 01:05
  • You're not making a copy of the list, FYI - you're just using a different variable to reference it. You should do `List temp = users.ToList();` to copy it – Blorgbeard Jun 06 '13 at 01:08
  • What do you want to happen if someone is added or leaves during `handOutCurrency`? I think what you're doing is the best way - it's simpler to just take a snapshot before working. – Blorgbeard Jun 06 '13 at 01:10
  • I would go with some kind of message queue. That way you can just keep processing messages. – Dustin Kingen Jun 06 '13 at 01:12
  • That's a good question. In the long run, I don't think an extra coin gained or lost here and there is going to make much of a difference over the long-term. It might just make more sense to do it like that in this case, anyways, because joins and parts are delayed to the client because of (what I assume) is a raw message queue by the server (IE, in a busy channel, you'll get chunks of 10+ joins/parts at once, every 40 seconds or so). – Keirathi Jun 06 '13 at 01:15

4 Answers4

7

I suggest using a ConcurrentBag<string> for the users.

This allows multi-threaded access to the users even while it is being enumerated.

The big plus is that you do not have to worry about locking.

Richard Schneider
  • 34,944
  • 9
  • 57
  • 73
  • 2
    List is ordered, ConcurrentBag is not.. OP may want to preserve order. – Blorgbeard Jun 06 '13 at 01:12
  • 1
    Given the above example, no ordering is needed. – Richard Schneider Jun 06 '13 at 01:13
  • True, but the example may not be complete - the difference is worth noting, I think. – Blorgbeard Jun 06 '13 at 01:14
  • 2
    Additional reading: [No ConcurrentList in .Net 4.0?](http://stackoverflow.com/questions/6601611/no-concurrentlistt-in-net-4-0) – Chris Jun 06 '13 at 01:15
  • @Chris - was just about to add that link! – Richard Schneider Jun 06 '13 at 01:16
  • Hmm, interesting suggestion. You're right, I don't care about the order at all. I'll give it a try, thanks! Funnily enough, I was just doing work with some ConcurrentDictionaries on another project earlier today. Not sure why I didn't think to see what other Concurrent objects there were available. Oops :o – Keirathi Jun 06 '13 at 01:16
  • 1
    I think `ConcurrentBag` won't work here, because you can't remove a specific item from it. – svick Jun 06 '13 at 01:26
  • Oh, didn't think that through very well. There's not any way I can see to remove a specific item from the bag. So for instance, if a user joined and parted during my handOutCurrency interval, I have no way to remove them from the bag. – Keirathi Jun 06 '13 at 01:28
  • You have no way to remove them by user name at all, regardless of whether they parted during a `handOutCurrency()` run or not. The only way to work around this would be to enumerate the elements with `TryTake()` and put all of them back in except for the user you wanted to remove. But this means that you will again need to lock the bag during user removal. – Chris Jun 06 '13 at 01:45
4

There are two ways to solve this problem:

  • Using a lock to synchronize access between two threads, or
  • Doing all access from a single thread.

The first way is simple: add lock(users) {...} block around the code that reads or modifies the users list.

The second way is slightly more involved: define two concurrent queues, toAdd and toRemove in your class. Instead of adding or removing users directly from the users list, add them to the toAdd and toRemove queues. When the sleeping thread wakes up, it should first empty both queues, performing the modifications as necessary. Only then it should hand out the currency.

ConcurrentQueue<string> toAdd = new ConcurrentQueue<string>();
ConcurrentQueue<string> toRemove = new ConcurrentQueue<string>();

private void parseIRCMessage(msg){
    if (msgType == "JOIN"){
        toAdd.Enqueue(user);
    }
    else if (msgType == "PART"){
        toRemove.Enqueue(user);
    }
}
private void doWork(){
    while (true) {
        string user;
        while (toAdd.TryDequeue(out user)) {
            users.Add(user);
        }
        while (toRemove.TryDequeue(out user)) {
            users.Remove(user);
        }
        if (streamOnline() && handOutTime()){
            handOutCurrency();
        }
        Thread.Sleep(60000);
    }
}
Sergey Kalinichenko
  • 714,442
  • 84
  • 1,110
  • 1,523
  • I like this answer a lot. I need to play around with everything more (I just implemented the locks first as a temporary solution), but this could be very useful in general. Marking this as the answer because I think both methods fit exactly what I am looking for. Thanks! – Keirathi Jun 06 '13 at 03:57
3

The suggestions from dasblinkenlight's answer are good. Another option is to do something similar to what you suggested: work with a immutable copy of the list. Except with normal List, you would need to make sure that it's not changed while you're copying it (and you would actually need to copy the list, not just a reference to it, like your code suggested).

A better version of this approach would be to use ImmutableList from the immutable collections library. With that, each modification creates a new collection (but sharing most parts with the previous version to improve efficiency). This way, you could have one thread that modifies the list (actually, creates new lists based on the old one) and you could also read the list from another thread at the same time. This will work, because new changes won't be reflected in an old copy of the list.

With that, your code would look something like this:

private ImmutableList<string> users = ImmutableList<string>.Empty;

private void ParseIRCMessage(string msg)
{
    if (msgType == "JOIN")
    {
        users = users.Add(user);
    }
    else if (msgType == "PART")
    {
        users = users.Remove(user);
    }
}

private void HandOutCurrency()
{
    foreach (String user in users)
    {
        database.AddCurrency(user, 1);
    }
}
svick
  • 236,525
  • 50
  • 385
  • 514
2

You need to lock on the list during all reads, writes, and iterations of the list.

private void parseIRCMessage(msg){
  lock(users)
  {
    if (msgType == "JOIN"){
        users.Add(user);
    }
    else if (msgType == "PART"){
        users.Remove(user);
    }
  }
}

private void doWork(){
    while (true) {
        if (streamOnline() && handOutTime()){
            handOutCurrency();
        }
    Thread.Sleep(60000);
    }
}

private void handOutCurrency(){
  lock(users)
  {
    foreach (String user in users) {
        database.AddCurrency(user, 1);
    }
  }
}

etc...

Jonathan Henson
  • 8,076
  • 3
  • 28
  • 52
  • Is that really an improvement? In terms of correctness, the end result should be the same: Users added during a `handOutCurrency()` will not get paid either way. Your solution removes the need for copying, but introduces the need for locking. Since copying is done only every 60 seconds and locking is done on every join, would it not be better in terms of computation time to stick with the old solution? – Chris Jun 06 '13 at 01:12
  • @Chris, as always, there are multiple ways of doing anything. There are always more complex ways of doing something. However, you do not have a race condition here, so the problem is solved, and in a simple and readable manner. Users cannot be added or removed during handOutCurrency--that is the point. – Jonathan Henson Jun 06 '13 at 01:14
  • @Chris, and a lock is certainly more efficient than a O(n) copy. – Jonathan Henson Jun 06 '13 at 01:16
  • @Chris, "Users added during a handOutCurrency() will not get paid either way." Did you read the code I wrote? Yes they will, this is entirely thread safe. – Jonathan Henson Jun 06 '13 at 01:19
  • Yes, sorry, that was bad wording on my part. I meant they will not get paid during the current `handOutCurrency()` run. – Chris Jun 06 '13 at 01:21