The cost of latent logging code

Logging is an integral part of most applications, whether it's for logging performance metrics or causality data. Avoiding performance hits due to logging can be tricky though as we don't want to spend CPU cycles on the logging infrastructure when logging is disabled, while still keeping the full logging ability when required.

Imagine the following scenario in which we want to log an exception in our application:

Logger.Log("An error occured at " + DateTime.Now + " on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");

Inside the Logger.Log method we may have a check for whether logging is enabled like the following:

if(LoggingEnabled)
	File.AppendAllText("Log.txt", logMessage + Environment.NewLine);

What's the problem? Although we do not touch the file system when logging is disabled, we still incur the rather large overhead of string concatenation and retrieving the machine and process name. Usually this overhead will be even larger depending on what extra information is logged as part of the actual log message. Yes, we could append the machine and process names within the Logger.Log method, but that's beyond the problem I'm describing.

We can avoid this by checking for LoggingEnabled in our actual application code:

if(Logger.LoggingEnabled)
	Logger.Log("An error occured at " + DateTime.Now + " on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");

While this does save us from doing string concatenation and retrieving other data when logging is disabled, it's rather ugly to have logging checks scattered around the application code.

An alternative to sending a log message directly to the Logger.Log method would be to send a Func that fetches the log message if needed:

public class Logger
{
	public static void Log(Func<string> message)
	{
		if (LoggingEnabled)
			File.AppendAllText("Log.txt", message() + Environment.NewLine);
	}
}

Logger.Log(() => "An error occured at " + DateTime.Now + " on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");

This has the big benefit of only executing the actual logging message functionality if logging is enabled, thus reducing the overhead to a near zero.

While this is rather straight forward as long as the logging is performed synchrounously, there's a pitfall if we perform asynchronous logging. Take the following asynchronous Logger implementation as an example:

public static class Logger
{
	private static Queue<Func<string>> logMessages = new Queue<Func<string>>();
	public static bool LoggingEnabled { get; set; }

	public static void Log(Func<string> message)
	{
		if (LoggingEnabled)
			logMessages.Enqueue(message);
	}

	public static void FlushMessages()
	{
		while(logMessages.Count > 0)
			File.AppendAllText("Log.txt", logMessages.Dequeue()() + Environment.NewLine);
	}
}

Instead of outputting the log message to the log file immediately when the Log function is called, we now store the actual log messages in a FIFO queue. At some point we'll call Logger.FlushMessages to flush out the messages to the text file. To optimize the process we'd usually concatenate the messages in a StringBuilder and perform just a single sequential write to disk, but to KISS I'm just writing out the messages one by one.

We'll perform a number of logs using the following code:

string date = DateTime.Now.ToString();

Logger.Log("An error occured at " + DateTime.Now + " (" + date + ") on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");

If you open the log file, you'll notice a discrepancy between the two dates that are logged, while you might expect them to be identical. As the actual lambda function is performed at log flush time instead of log time, the DateTime.Now variable will include the flush moment instead of the original logging moment.

The solution in this case is simple. All we need to do is to store the results of the log Funcs instead of the actual funcs:

public static class Logger
{
	private static Queue<string> logMessages = new Queue<string>();
	public static bool LoggingEnabled { get; set; }

	public static void Log(Func<string> message)
	{
		if (LoggingEnabled)
			logMessages.Enqueue(message());
	}

	public static void FlushMessages()
	{
		while(logMessages.Count > 0)
			File.AppendAllText("Log.txt", logMessages.Dequeue() + Environment.NewLine);
	}
}

We can still implement asynchronous logging, as long as the actual log message is retrieved synchronously or we make sure the logging Func only references local immutable variables - though the last case kinda destroys the performance gain.

string date = DateTime.Now.ToString();

Logger.Log(() => "An error occured at " + date + " on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");

To sum up the speed gains of using deferrered lambda logging, I've implemented a simple synchronous Logger implementation:

public static class Logger
{
	public static bool LoggingEnabled { get; set; }

	public static void Log(string message)
	{
		if (LoggingEnabled)
			File.AppendAllText("Log.txt", message + Environment.NewLine);
	}

	public static void Log(Func<string> message)
	{
		if (LoggingEnabled)
			File.AppendAllText("Log.txt", message() + Environment.NewLine);
	}
}

And to perform the actual performance measurements I'm using my CodeProfiler class with 1000 iterations of the logging code:

class Program
{
	static void Main(string[] args)
	{
		Logger.LoggingEnabled = true;
		var timeWithLoggingEnabled = profileCode();
		var lambdaTimeWithLoggingEnabled = profileLambdaCode();

		Logger.LoggingEnabled = false;
		var timeWithLoggingDisabled = profileCode();
		var lambdaTimeWithLoggingDisabled = profileLambdaCode();

		Console.WriteLine("Logging enabled: " + timeWithLoggingEnabled.TotalMilliseconds);
		Console.WriteLine("Lambda logging enabled: " + lambdaTimeWithLoggingEnabled.TotalMilliseconds);
		Console.WriteLine("Logging disabled: " + timeWithLoggingDisabled.TotalMilliseconds);
		Console.WriteLine("Lambda logging disabled: " + lambdaTimeWithLoggingDisabled.TotalMilliseconds);
		Console.Read();
	}

	static TimeSpan profileCode()
	{
		return CodeProfiler.ProfileAction(() =>
		{
			Logger.Log("An error occured at " + DateTime.Now + " on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");
		}, 1000);
	}

	static TimeSpan profileLambdaCode()
	{
		return CodeProfiler.ProfileAction(() =>
		{
			Logger.Log(() => "An error occured at " + DateTime.Now + " on computer " + Environment.MachineName + " in process" + Process.GetCurrentProcess().ProcessName + ".");
		}, 1000);
	}
}

The results:

Logging enabled: 1440,2764
Lambda logging enabled: 1483,0738
Logging disabled: 763,1717
Lambda logging disabled: 0,6516

As we can see from the results, even with logging disabled it still costs us 763ms using the normal logging procedure. By deferring the execution of the log message we only incur an overhead of 0,65ms when logging is disabled. When logging is enabled the execution costs are ~identical.

kick it on DotNetKicks.com


Comments

Carel Lotz | Sep 18th, 2009, 10:47 AM

Another downfall of this approach is that you cannot automatically log the place where the logging occured as the log statement is deferred via the lamda expression. This quickly breaks up the flow of your log statements as it doesn't accurately reflect where the logging statement was made from.

nappisite | Sep 18th, 2009, 2:13 PM

Interesting idea, worth further invesigation.

Damian Powell | Sep 18th, 2009, 3:35 PM

For a website, do we really care about spending 2 seconds logging for every 1000 requests served? My guess is no. Especially when we compare with the potential time required for tracking down a multi-threading bug caused by using an asynchronous logging implementation, or trying to understand why "my application doesn't work but as soon as I turn on logging, the bug goes away" which is caused by a side effect of conditionally executing logging logic. Anyone remember the Release-build-only bugs in C++?

My default position is to do logging synchronously, leave it turned on all the time, and optimize *if* there is hard evidence of a performance problem. Even if you do hit a performance problem it's likely to be cheaper (in a web site) to scale out than to spend time investigating and fixing it.

Mark S. Rasmussen | Sep 19th, 2009, 3:39 AM

@Carel
Why can you no longer automatically log the stack at log time? As long as the actual logging lambda is being executed synchronously (as I recommend) you should be able to pull the current stack frame and get the calling method instead of the actual method.

@Damian
I trust you realise that this code wasn't specific for websites, it's an issue in both desktop/website code.

I actually think 2 seconds is a lot for 1000 requests. Note that this is for 1000 log calls - and there may be several log calls per request, especially if we log across multiple layers in our architecture.

The logging infrastructure really isn't complex provided you execute the lambda in a synchronous way as I recommend - the actual persistance of log messages may be asynchronous, but that's straightforward to implement. A lot of logging frameworks today will give you various ways of persisting it - this method can you be a wrapper on top of an existing logging framework.

Finally, the cost per log-call will of course vary on what you're doing in the log call. If you're just logging static strings then you won't gain anything by deferring the execution. If instead you're doing some complex logic, the gain will be bigger.

Damian Powell | Sep 19th, 2009, 1:24 PM

@Mark,

Yes, I realise that your code wasn't specific to a website, but my reply was. That said, depending on how a a smart client app works, my comments might be applicable there too. Although, obviously 100 calls in a tight processing loop is quite possible in some sort of data processing app and would be a bad idea. However, a few tens or hundreds of calls during the creation of a page on a website isn't going to be so bad when considering the latency of the web and the database - unless you're Amazon!

Don't get me wrong, I like the lambda syntax for conditional logging, it's very neat. My problem is with the idea of conditionally executing logging statments at all. Even 'if (logger.IsEnalbed) { ... }' is not a good idea if there's anything in the braces other than calls to logger and string concatenation. The reason I say that is becase I've seen too many problems in the past (in C++ code) where someone has used a macro to conditionally trace debug output which has then caused different behaviour in a release build. In C#, it's more likely to be something like a property getter that has a sneaky side effect that causes the problem.

Erich Eichinger | Sep 20th, 2009, 6:39 PM

If you are looking for a full, ready-to-use implementation of this strategy, I already implemented it a while ago in the Common.Logging library available at http://netcommon.sourceforge.net/, see also my blog post at http://eeichinger.blogspot.com/2009/05/common-logging-20-for-net-released.html to see how it works.

Add comment

After you have posted a comment, an email will be sent to the provided email address. Before your comment is activated, you will have to click the confirmation link within the email.

Name:

Email (only used for validation):

Website (optional):

Message:

Notify me when new comments are added:

Please type the following letters into the box below:  

Post!