Within the closure of a delegate, variables can be captured and references to them retained within the scope of the delegate. This can lead to some interesting behavior. Here's an example:
class Program { static void Main(string[] args) { var del = CreateDelegate(); del(); del(); } static MethodInvoker CreateDelegate() { var x = 0; MethodInvoker ret = delegate { x++; }; return ret; } }
Stepping through this code and calling "x" in the command window demonstrates what's happening here. When you're in Main(), "x" doesn't exist. The command window returns an error saying that it can't find it. But with each call to the delegate, internally there is a reference to a single "x" which increments each time (thus ending with a value of 2 here).
So the closure has captured a reference to "x" and that data remains on the heap so that the delegate can use it later, even though there's no reference to it in scope when the delegate isn't being called. Pretty cool. But what happens if that reference is something more, well, IDisposable? Something like this:
class Program { static void Main(string[] args) { var del = CreateDelegate(); del(); del(); } static MethodInvoker CreateDelegate() { var x = 0; using (var txt = File.OpenWrite(@"C:\Temp\temp.txt")) { MethodInvoker ret = delegate { x++; txt.Write(Encoding.ASCII.GetBytes(x.ToString()), 0, x.ToString().Length); }; return ret; } } }
This looks a little scary, given the behavior we've already seen. Is that "using" block going to dispose of the file handle? When will it dispose of it? Will that reference continue to exist on the heap when it's not in scope?
Testing this produces an interesting result. The reference to "x" continues to work as it did previously. And the reference to "txt" seems to also be maintained. But the file handle is no longer open. It appears that when the CreateDelegate() method returns, that "using" block does properly dispose of the resource. The reference still exists, but the file is now closed and attempting to write to it when the delegate is first called results in the proper exception as a result.
So let's try something a little messier:
class Program { static void Main(string[] args) { var del = CreateDelegate(); del(); del(); } static MethodInvoker CreateDelegate() { var x = 0; var txt = File.OpenWrite(@"C:\Temp\temp.txt"); MethodInvoker ret = delegate { x++; txt.Write(Encoding.ASCII.GetBytes(x.ToString()), 0, x.ToString().Length); }; return ret; } }
Now we're not disposing of the file handle. Given the previous results, the results of this are no surprise. Once the delegate is created, the file handle is open and is left open. (While stepping through the debugger I'd go back out to the file system and see if I can re-name the file, and indeed it would not let me because it was in use by another process. It wouldn't even let me open it in Notepad.) Each call to the delegate successfully writes to the file.
It's worth noting that the file handle was properly disposed by the framework when the application terminated. But what if this process doesn't terminate in an expected way? What if this is a web app or a Windows service? That file handle can get pretty ugly. It's worth testing those scenarios at a later time, but for now let's just look at what happens when the system fails in some way:
class Program { static void Main(string[] args) { var del = CreateDelegate(); del(); del(); Environment.FailFast("testing"); } static MethodInvoker CreateDelegate() { var x = 0; var txt = File.OpenWrite(@"C:\Temp\temp.txt"); MethodInvoker ret = delegate { x++; txt.Write(Encoding.ASCII.GetBytes(x.ToString()), 0, x.ToString().Length); }; return ret; } }
The behavior is the same, including the release of the file handle after the application terminates (which actually surprised me a little, but I'm glad it happened), except for one small difference. No text was written to the file this time. It would appear that the captured file handle in this case doesn't flush the buffer until either it's disposed or some other event causes it to flush. Indeed, this was observed in Windows Explorer as I noticed that the file continued to be 0 bytes in size while the delegate was being called. In this last test, it stayed 0 bytes because it was never written to. In the previous test, it went directly from 0 bytes to 2 bytes when the application exited.
I wonder if anybody has ever fallen into a trap like this in production code. I certainly hope not, but I guess it's possible. Imagine a home-grown logger which just has a delegate to a logging function that writes to a file. That log data (and, indeed, all log data leading up to it) will be lost if it's never properly disposed. And it may not be entirely intuitive to developers working in the system unless they really take a look at that logging code (which they shouldn't have to do, it should just work).
I kind of want to come across something like this in a legacy application someday, if for no other reason than to see what the real-world fallout would look like.
No comments:
Post a Comment