Locking -- Isolation -- Unit of Work -- Performance -- Solution

A few days ago I posted a concurrency problem for commentary and I got a very nice set of responses. 

Now I can't say that there is any answer to this problem that I would say is universally correct but there are some interesting observations that we can/should make about the proffered code.  Many of these points were covered in the commentary but not everything was so hopefully you’ll still find it interesting to read this.  Again, as always, forgive me for generalizing in the interest of greater clarity in some cases below.

Now the first solution had one lock controlling all the access.  This has perhaps the most understandable behavior of the two solutions and maybe about as understandable as we can get.  

     // it's easy to explain what this does
    class ExampleCoarse
    {
        public ExampleCoarse() {}
        private Object myLock = new Object();
        private int count;

        public void DoIt(StreamWriter sw)
        {
            lock(myLock)
            {
                long ticks = DateTime.Now.Ticks;
                int count = IncrementCount();
                LogToStream(sw, ticks, count);
            }
        }

        private int IncrementCount()
        {
            return ++count;
        }

        private void LogToSteam(StreamWriter sw, long ticks, int count)
        {
            sw.WriteLine("{0} {1}", ticks, count);
        }
    }

The first thing to notice is that I said in the assumptions that there was only going to be one instance of this object – and yet the object has no static members.  Presumably that was done to allow for several logically independent copies of the object at some point in the future.  If the “count” and “myLock” members were truly static then the single instance choice would be locked in forever.  As it stands the users of the object are sort of forced to deal with the fact that there is some shared instance because they must provide the “this” pointer.  Fair enough I suppose.

But even in this simplest form of the code there are at least two other concerns.  It appears that writing to the stream will be serialized … but will it?  Well not exactly.  The StreamWriter (sw) provided is external to the class.  We have no idea if it is going to be shared by other threads.  If it is then it will have to be the case that this is the only place that StreamWriter is used because otherwise lock(myLock) will not offer any protection (see “Same Resource, Same Lock or else locking won't give you the protection you need” for more details on this).

That’s bad enough.  But can we say that it is wise to even use the external StreamWriter?  What does it do?  The trouble with StreamWriters is that they abstract the underlying stream with a nice interface.  Coincidentally that’s also what’s good about them.  So what is going to Actually Happen when we call sw.WriteLine()?  Well the answer is pretty much anything could happen.  For all we know the underlying Stream synchronously sends a letter to Grandma via US certified return-receipt air mail.  So is it a good idea to call such a method inside a lock?  Well… if we have a good understanding of what the stream is going to be for other reasons (not shown here) then maybe. But if this was framework code, it might be an astonishingly bad idea indeed.

Looks like for even the simple code, as written, to get good results it needs to make some pretty significant assumptions.

Let's have a look at the second version now:

     // it's not so easy to explain this... or how to use it.. but is it better?
    class ExampleFine
    {
        public ExampleFine() {}
        private Object myLock = new Object();
        private int count;

        public void DoIt(StreamWriter sw)
        {
            long ticks = DateTime.Now.Ticks;
            int count = IncrementCount();
            LogToStream(sw, ticks, count);
        }

        private int IncrementCount()
        {
            int c = System.Threading.Interlocked.Increment(count);
            return c;
        }

        private void LogToSteam(StreamWriter sw, long ticks, int count)
        {
            lock (myLock)
            {
                sw.WriteLine("{0} {1}", ticks, count);
            }
        }
    }

That's of course more complicated, but what does it do?  Well naturally it has all the same issues as the "easy" case. But additionally it suffers from the fact that it's basically impossible to characterize the output.  Counts and ticks do not necessarily increase together and they can also be out of order in the file.  The count could be arbitrarily unrelated to the ticks.  Is it at least faster?  Hrm... maybe... it does have finer locks but then the interlocked operation isn't cheap either and we don't know if the stream is fast (like say a memory stream) or slow (like that letter to Gramma).  If the streaming operation is comparatively quick then contention would be low and the extra locks would just slow us down.  On the other hand a very slow stream will dominate the cost as everyone is gonna be waiting for Gramma  for so long that the rest of the time isn’t going to matter a whit.  So somewhere in between there would be a cutoff where the extra concurrency is paying for the extra locking.

Wow that’s complicated.

So in return for getting streamed output that’s totally meaningless we get…maybe nothing at all.  And don’t forget the Gramma stream could be asynchronous – further confusing the situation for both cases.

I’m not really liking that second choice very much at all at this point.

Could we do better?  Yes I think we could.  Here’s a couple of suggestions:

     // it's easy to explain what this does
    class ExampleHybrid
    {
        public ExampleHybrid() {}
        private Object myLock = new Object();
        private int count;

        public void DoIt(StreamWriter sw)
        {
            lock(myLock)
            {
                long ticks = DateTime.Now.Ticks;
                int count = IncrementCount();
            }

            LogToStream(sw, ticks, count);
        }

        private int IncrementCount()
        {
            return ++count;
        }

        private void LogToSteam(StreamWriter sw, long ticks, int count)
        {
            sw.WriteLine("{0} {1}", ticks, count);
        }
    }

In this case we’re basically coming right out and saying to our callers that the StreamWriter (sw) they provide us better not be shared with other threads, or else it better be internally threadsafe. Either way we’re staying out of it. And by the way we aren’t guaranteeing the output order but we are guaranteeing that at least the tick will match the counts in some meaningful way. It’s an interesting compromise – and actually easier to understand than the previous case. If the stream is slow at least we won’t be holding a lock while it does its business.

And maybe one last choice for discussion

     // it's easy to explain what this does
    class ExampleNoOutput
    {
        public ExampleNoOutput() {}
        private Object myLock = new Object();
        private int count;

        public void DoIt(out long ticksOut, out int countOut)
        {
            lock(myLock)
            {
                ticksOut = DateTime.Now.Ticks;
                countOut = IncrementCount();
            }
        }

        private int IncrementCount()
        {
            return ++count;
        }
    }

And in this last example we finesse the output issues by declining to take a dependence on the stream entirely.  “Leave me out of your output logging problems”, says this class, “I wash my hands of all that messy I/O”.

This could perhaps be the wisest course of all.  Although the services are more modest they are readily understood and any desired logging strategy can be built on top of them.  Whatever chuncking, synchronization, etc. is up to higher level code that is likely be better capable of making that decision intelligently.  Moving synchronization primitives “up the stack” to code with more overall “awareness” of what is going on is a common theme to get more sensible synchronization.  Definitely something you should keep in mind.

Is there an overall winner?  Hardly.  Though I think the second of the two original versions is probably the overall loser.

Thanks very much for the thoughtful comments on the first posting.