Wicked Code
Scalable Apps with Asynchronous Programming in ASP.NET
Jeff Prosise
Code download available at: Wicked Code 2007_03.exe(202 KB)
Contents
Asynchronous Pages
Asynchronous HTTP Handlers
Asynchronous HTTP Modules
Conclusion
Do you want to know a secret? A deep, dark, dirty secret? One that, if revealed, would cause great angst in the ASP.NET community and prompt shouts of "Aha!" from the anti-Microsoft crowd?
Most Web sites I've seen built with ASP.NET aren't very scalable, not because of a flaw in ASP.NET, but because of how the technology is used. They suffer a self-imposed glass ceiling that limits the number of requests they can process per second. These sites scale just fine until traffic rises to the level of this invisible ceiling. Then throughput begins to degrade. Soon after, requests start to fail, usually returning "Server unavailable" errors.
The underlying reason has been discussed many times in MSDN®Magazine. ASP.NET uses threads from a common language runtime (CLR) thread pool to process requests. As long as there are threads available in the thread pool, ASP.NET has no trouble dispatching incoming requests. But once the thread pool becomes saturated-that is, all the threads inside it are busy processing requests and no free threads remain-new requests have to wait for threads to become free. If the logjam becomes severe enough and the queue fills to capacity, ASP.NET throws up its hands and responds with a "Heck no!" to new requests.
One solution is to increase the maximum size of the thread pool, allowing more threads to be created. That's the course developers often take when their customers report repeated "Server unavailable" errors. Another common course of action is to throw hardware at the problem, adding more servers to the Web farm. But increasing the thread count-or the server count-doesn't solve the issue. It just provides temporary relief to what is in reality a design problem-not in ASP.NET itself, but in the implementation of the actual site. The real problem for apps that don't scale isn't lack of threads. It's inefficient use of the threads that are already there.
A truly scalable ASP.NET Web site makes optimum use of the thread pool. That means making sure request-processing threads are executing code instead of waiting for I/O to complete. If the thread pool becomes saturated due to all the threads grinding away on the CPU, there's little you can do but add servers.
However, most Web apps talk to databases, Web services, or other external entities, and limit scalability by forcing thread-pool threads to wait for database queries, Web service calls, and other I/O operations to complete. A request targeting a data-driven Web page might spend a few thousandths of a second executing code and several seconds waiting for a database query to return. While the query is outstanding, the thread assigned to the request is unable to service other requests. That's the glass ceiling. And that's a situation you must avoid if you care about building highly scalable Web sites. Remember: when it comes to throughput, I/O is evil unless handled properly.
I/O isn't evil, though, if it doesn't gum up the thread pool. And ASP.NET supports three asynchronous programming models that act as anti-gumming agents. These models are largely unknown to the community, in part due to scant documentation. Yet knowing how-and when-to use them is absolutely essential to building cutting-edge Web sites.
Asynchronous Pages
The first, and generally most useful, of the three asynchronous programming models ASP.NET supports is the asynchronous page. Of the three models, this is the only one that's specific to ASP.NET 2.0. The others are supported all the way back to version 1.0.
I won't go into detail about asynchronous pages here because I did that in the October 2005 issue (msdn.microsoft.com/msdnmag/issues/05/10/WickedCode). The upshot is that if you have pages that perform relatively lengthy I/O operations, they're candidates to become asynchronous pages. If a page queries a database and the query takes, say, 5 seconds to return because it either returns a large amount of data or targets a remote database over a heavily loaded connection, that's 5 seconds that the thread assigned to the request can't be used for other requests. If every request behaved like this, the application would quickly get bogged down.
Figure 1 illustrates how an asynchronous page neatly solves this problem. When the request arrives, it's assigned a thread by ASP.NET. The request begins processing on that thread, but when the time comes to hit the database, the request launches an asynchronous ADO.NET query and returns the thread to the thread pool. When the query completes, ADO.NET calls back to ASP.NET, and ASP.NET grabs another thread from the thread pool and resumes processing the request.
Figure 1** Asynchronous Pages at Work **(Click the image for a larger view)
While the query is outstanding, zero thread pool threads are consumed, leaving all of the threads free to service incoming requests. A request that's processed asynchronously doesn't execute any faster. But other requests execute faster because they don't have to wait for threads to become free. Requests incur less delay in entering the pipeline, and overall throughout goes up.
Figure 2 shows the codebehind class for an asynchronous page that performs data binding against a SQL Server™ database. The Page_Load method calls AddOnPreRenderCompleteAsync to register begin and end handlers. Later in the request's lifetime, ASP.NET calls the begin method, which launches an asynchronous ADO.NET query and returns immediately, whereupon the thread assigned to the request goes back into the thread pool. When ADO.NET signals that the query has completed, ASP.NET retrieves a thread from the thread pool (not necessarily the same one it used before) and calls the end method. The end method grabs the query results and the remainder of the request executes as normal on the thread that executed the end method.
Figure 2 Asynchronous Page
using System;
using System.Data;
using System.Data.SqlClient;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.Configuration;
public partial class AsyncDataBind : System.Web.UI.Page
{
private SqlConnection _connection;
private SqlCommand _command;
private SqlDataReader _reader;
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
AddOnPreRenderCompleteAsync(
new BeginEventHandler(BeginAsyncOperation),
new EndEventHandler(EndAsyncOperation)
);
}
}
IAsyncResult BeginAsyncOperation (object sender, EventArgs e,
AsyncCallback cb, object state)
{
string connect = WebConfigurationManager.ConnectionStrings
["AsyncPubs"].ConnectionString;
_connection = new SqlConnection(connect);
_connection.Open();
_command = new SqlCommand(
"SELECT title_id, title, price FROM titles", _connection);
return _command.BeginExecuteReader (cb, state);
}
void EndAsyncOperation(IAsyncResult ar)
{
_reader = _command.EndExecuteReader(ar);
}
protected void Page_PreRenderComplete(object sender, EventArgs e)
{
Output.DataSource = _reader;
Output.DataBind();
}
public override void Dispose()
{
if (_connection != null) _connection.Close();
base.Dispose();
}
}
Not shown in Figure 2 is the Async="true" attribute in the ASPX's Page directive. This is required for an asynchronous page: it signals ASP.NET to implement the IHttpAsyncHandler interface in the page (more on this in a moment). Also not shown in Figure 2 is the database connection string, which includes an Async="true" attribute of its own so that ADO.NET knows to perform an asynchronous query.
AddOnPreRenderCompleteAsync is one way to structure an asynchronous page. Another way is to call RegisterAsyncTask. This has a couple of advantages over AddOnPreRenderCompleteAsync-the most important being that it simplifies the task of performing multiple asynchronous I/O operations in one request. For details on this, see the October 2005 installment of Wicked Code.
Asynchronous HTTP Handlers
The second asynchronous programming model featured in ASP.NET is the asynchronous HTTP handler. An HTTP handler is an object that serves as an endpoint for requests. Requests for ASPX files, for example, are processed by an HTTP handler for ASPX files. Likewise, requests for ASMX files are handled by an HTTP handler that knows how to deal with ASMX services. In fact, ASP.NET comes with HTTP handlers for a variety of file types. You can see these file types-and the corresponding HTTP handlers-in the <httpHandlers> section of the master web.config file (in ASP.NET 1.x, it's in machine.config).
You can extend ASP.NET to support additional file types by writing custom HTTP handlers. But even more interesting is the fact that you can deploy custom HTTP handlers in ASHX files and use them as targets of HTTP requests. This is the proper way to build Web endpoints that generate images on the fly or retrieve images from databases. You simply include an <img> tag (or Image control) in the page and point it to an ASHX that creates or fetches the image. Targeting an ASHX file with requests is more efficient than targeting an ASPX file because an ASHX file incurs much less overhead at processing time.
By definition, HTTP handlers implement the IHttpHandler interface. Handlers that implement that interface do their processing synchronously. The ASHX file in Figure 3 contains one such HTTP handler. At run time, TerraServiceImageGrabber makes multiple calls out to the Microsoft® TerraServer Web service to convert a city and state into latitude and longitude, retrieve satellite images ("tiles"), and stitch the images together to form a composite image of the specified location.
Figure 3 Synchronous HTTP Handler
<%@ WebHandler Language="C#" Class="TerraServiceImageGrabber" %>
using System;
using System.Web;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
public class TerraServiceImageGrabber : IHttpHandler
{
public void ProcessRequest (HttpContext context)
{
// Extract user input from the query string
string city = context.Request["City"];
string state = context.Request["State"];
string scale = context.Request["Scale"];
// If city or state wasn't specified, throw an exception
if (String.IsNullOrEmpty(city) || String.IsNullOrEmpty(state))
throw new ArgumentException(
"City and state must be specified via query string");
// Determine the scale
Scale res = Scale.Scale8m;
if (!String.IsNullOrEmpty (scale))
{
switch (scale)
{
case "1":
res = Scale.Scale1m;
break;
case "2":
res = Scale.Scale2m;
break;
case "4":
res = Scale.Scale4m;
break;
case "8":
res = Scale.Scale8m;
break;
case "16":
res = Scale.Scale16m;
break;
case "32":
res = Scale.Scale32m;
break;
}
}
// Generate the requested image
using(Bitmap bitmap = GetTiledImage(city, state, res, 900, 600))
{
// Set the response's content type
context.Response.ContentType = "image/jpeg";
// Write the image to the HTTP response
bitmap.Save(context.Response.OutputStream, ImageFormat.Jpeg);
}
}
public bool IsReusable { get { return true; } }
private Bitmap GetTiledImage (string city, string state,
Scale scale, int cx, int cy)
{
// Instantiate the TerraService proxy
TerraService ts = new TerraService ();
// Get the latitude and longitude of the requested city
Place place = new Place ();
place.City = city;
place.State = state;
place.Country = "USA";
LonLatPt point = ts.ConvertPlaceToLonLatPt (place);
// Compute the parameters for a bounding box
AreaBoundingBox abb = ts.GetAreaFromPt (point, Theme.Photo,
scale, cx, cy);
// Create an image to fit the bounding box
Bitmap bitmap = new Bitmap (cx, cy, PixelFormat.Format32bppRgb);
using(Graphics g = Graphics.FromImage(bitmap))
{
int x1 = abb.NorthWest.TileMeta.Id.X;
int y1 = abb.NorthWest.TileMeta.Id.Y;
int x2 = abb.NorthEast.TileMeta.Id.X;
int y2 = abb.SouthWest.TileMeta.Id.Y;
for (int x=x1; x<=x2; x++) {
for (int y=y1; y>=y2; y--) {
TileId tid = abb.NorthWest.TileMeta.Id;
tid.X = x;
tid.Y = y;
using(Image tile = Image.FromStream(
new MemoryStream(ts.GetTile(tid))))
{
g.DrawImage(tile,
(x - x1) * tile.Width -
(int) abb.NorthWest.Offset.XOffset,
(y1 - y) * tile.Height -
(int) abb.NorthWest.Offset.YOffset,
tile.Width, tile.Height);
}
}
}
}
// Return the image
return bitmap;
}
}
The results are shown in Figure 4. The page shown contains an Image control whose ImageUrl property targets the ASHX file in Figure 3. Once the user selects a city and state and clicks a button, the HTTP handler converts the input into a satellite image.
The results are impressive. But there's a catch. TerraServiceImageGrabber is a perfect example of how not to write HTTP handlers. Think about it. TerraServiceImageGrabber requires several seconds (at least) to make all its Web service calls and process the results. The vast majority of that time is spent simply waiting for Web service calls to complete. Repeated requests for the ASHX file can deplete the ASP.NET thread pool in a fraction of a second, preventing other pages in the application from being served up (or at least forcing them to be queued until a thread becomes available). You can't build a scalable application in this way unless you scale out the hardware. But why spend tens of thousands of dollars on a Web farm when one server might handle the load with properly written software?
Figure 4** TerraServiceImageGrabber at Work **(Click the image for a larger view)
HTTP handlers don't have to be synchronous. By implementing the IHttpAsyncHandler interface, which itself derives from IHttpHandler, an HTTP handler can be asynchronous. When used correctly, an asynchronous handler utilizes ASP.NET threads more efficiently. This is done in the same manner as an asynchronous page. In fact, asynchronous pages leverage the asynchronous handler support that predated asynchronous pages in ASP.NET.
Figure 5 contains an asynchronous version of the handler shown in Figure 3. Async-TerraServiceImageGrabber is slightly more complex, but it is also far more scalable.
Figure 5 Asynchronous HTTP Handler
<%@ WebHandler Language="C#" Class="AsyncTerraServiceImageGrabber" %>
using System;
using System.Web;
using System.Drawing;
using System.Drawing.Imaging;
using System.Threading;
using System.IO;
public class AsyncTerraServiceImageGrabber : IHttpAsyncHandler
{
private TerraService _ts;
private TerraServiceAsyncResult _ar;
private Scale _scale = Scale.Scale8m;
private AreaBoundingBox _abb;
private Bitmap _bitmap;
private int _count = 0;
private int _max;
private HttpContext _context;
private Exception _ex;
private int _cx = 900, _cy = 600; // Width and height of bitmap
public void ProcessRequest (HttpContext context)
{
// Never called
}
public bool IsReusable { get { return false; } }
public IAsyncResult BeginProcessRequest(HttpContext context,
AsyncCallback cb, object state)
{
_context = context;
// Extract user input from the query string
string city = context.Request["City"];
string region = context.Request["State"];
string scale = context.Request["Scale"];
// If city or state wasn’t specified, throw an exception
if (String.IsNullOrEmpty(city) || String.IsNullOrEmpty(region))
throw new ArgumentException(
"City and state must be specified via query string");
// Determine the scale
if (!String.IsNullOrEmpty (scale))
{
switch (scale)
{
case "1":
_scale = Scale.Scale1m;
break;
case "2":
_scale = Scale.Scale2m;
break;
case "4":
_scale = Scale.Scale4m;
break;
case "8":
_scale = Scale.Scale8m;
break;
case "16":
_scale = Scale.Scale16m;
break;
case "32":
_scale = Scale.Scale32m;
break;
}
}
// Instantiate a TerraServiceAsyncResult
_ar = new TerraServiceAsyncResult(cb, state);
// Instantiate the TerraService proxy
_ts = new TerraService();
// Make an async call to get the latitude and longitude
// of the requested city
Place place = new Place();
place.City = city;
place.State = region;
place.Country = "USA";
_ts.BeginConvertPlaceToLonLatPt(place,
new AsyncCallback(ConvertPlaceToLonLatCompleted), null);
// Return an IAsyncResult that delays EndProcessRequest until
// the final asynchronous Web service call has completed
return _ar;
}
private void ConvertPlaceToLonLatCompleted(IAsyncResult ar)
{
try
{
// Complete the async call
LonLatPt point = _ts.EndConvertPlaceToLonLatPt(ar);
// Make an async call to compute the parameters
// for a bounding box
_ts.BeginGetAreaFromPt(point, Theme.Photo, _scale, _cx,
_cy, new AsyncCallback(GetAreaFromPointCompleted), null);
}
catch (Exception ex)
{
_ex = ex;
_ar.CompleteCall();
}
}
private void GetAreaFromPointCompleted(IAsyncResult ar)
{
try
{
// Complete the async call
_abb = _ts.EndGetAreaFromPt(ar);
// Create an image to fit the bounding box
_bitmap = new Bitmap(_cx, _cy, PixelFormat.Format32bppRgb);
int x1 = _abb.NorthWest.TileMeta.Id.X;
int y1 = _abb.NorthWest.TileMeta.Id.Y;
int x2 = _abb.NorthEast.TileMeta.Id.X;
int y2 = _abb.SouthWest.TileMeta.Id.Y;
_max = (x2 - x1 + 1) * (y1 - y2 + 1);
// Place concurrent async calls to TerraService to
// fetch image tiles
for (int x = x1; x <= x2; x++)
{
for (int y = y1; y >= y2; y--)
{
TileId tid = new TileId();
tid.Theme = _abb.NorthWest.TileMeta.Id.Theme;
tid.Scale = _abb.NorthWest.TileMeta.Id.Scale;
tid.Scene = _abb.NorthWest.TileMeta.Id.Scene;
tid.X = x;
tid.Y = y;
_ts.BeginGetTile(tid,
new AsyncCallback(GetTileCompleted),
new Point(x - x1, y1 - y));
}
}
}
catch (Exception ex)
{
_ex = ex;
_ar.CompleteCall();
}
}
private void GetTileCompleted(IAsyncResult ar)
{
try
{
// Complete the async call
using(Image tile = Image.FromStream(
new MemoryStream(_ts.EndGetTile(ar))))
{
// Draw the tile onto the bitmap
Point point = (Point)ar.AsyncState;
int dx = point.X;
int dy = point.Y;
lock (_bitmap)
{
using(Graphics g = Graphics.FromImage(_bitmap))
{
g.DrawImage(tile,
dx * tile.Width –
(int)_abb.NorthWest.Offset.XOffset,
dy * tile.Height –
(int)_abb.NorthWest.Offset.YOffset,
tile.Width, tile.Height);
}
}
// Increment the tile count and complete the request if all
// tiles have been fetched
int count = Interlocked.Increment(ref _count);
if (count == _max) _ar.CompleteCall();
}
catch (Exception ex)
{
_ex = ex;
_ar.CompleteCall();
}
}
public void EndProcessRequest(IAsyncResult ar)
{
if (_ex != null)
{
// If an exception was thrown, rethrow it
throw _ex;
}
else
{
// Otherwise return the generated image
_context.Response.ContentType = "image/jpeg";
_bitmap.Save(_context.Response.OutputStream,
ImageFormat.Jpeg);
_bitmap.Dispose();
}
}
}
class TerraServiceAsyncResult : IAsyncResult
{
private AsyncCallback _cb;
private object _state;
private ManualResetEvent _event;
private bool _completed = false;
private object _lock = new object();
public TerraServiceAsyncResult(AsyncCallback cb, object state)
{
_cb = cb;
_state = state;
}
public Object AsyncState { get { return _state; } }
public bool CompletedSynchronously { get { return false; } }
public bool IsCompleted { get { return _completed; } }
public WaitHandle AsyncWaitHandle
{
get
{
lock (_lock)
{
if (_event == null)
_event = new ManualResetEvent(IsCompleted);
return _event;
}
}
}
public void CompleteCall()
{
lock (_lock)
{
_completed = true;
if (_event != null) _event.Set();
}
if (_cb != null) _cb(this);
}
}
Asynchronous processing begins when ASP.NET calls the handler's BeginProcessRequest method. BeginProcessRequest makes an async call to TerraService via the TerraService proxy's BeginConvertPlaceToLonLatPt method. The thread assigned to the request then goes back into the thread pool. When the async call completes, another thread is borrowed from the thread pool to execute the ConvertPlaceToLonLatCompleted method. That thread retrieves the results of the last call, makes an async call of its own, and then goes back into the thread pool. This pattern repeats until all the asynchronous calls have completed, at which time the handler's EndProcessRequest method is called and the resulting Bitmap is returned to the requestor.
To forestall EndProcessRequest until the final Web service call has completed, AsyncTerraServiceImageGrabber returns its own implementation of IAsyncResult from BeginProcessRequest. If it returned the IAsyncResult returned by BeginConvertPlaceToLonLatPt instead, EndProcessRequest would be called (and the request terminated) the moment the first Web service call completed.
The class that implements IAsyncResult, TerraServiceAsyncResult, features a public CompleteCall method that can be called at any time to finish the request. Normally, AsyncTerraServiceImageGrabber calls CompleteCall only after the final Web service call has completed. However, if an exception is thrown in one of the methods that executes between BeginProcessRequest and EndProcessRequest, the handler caches the exception in a private field (_ex), calls CompleteCall to terminate the request, and then rethrows the exception from EndProcessRequest. Otherwise, the exception would be lost and the request would never complete.
AsyncTerraServiceImageGrabber is more scalable than its synchronous counterpart since it consumes ASP.NET threads for only a fraction of the total time required to process a request. The vast majority of the time, it's simply waiting for an asynchronous Web service call to complete.
In theory, AsyncTerraServiceImageGrabber can also outperform TerraServiceImageGrabber because, rather than making repeated calls to TerraService's GetTile method in series, it makes the calls in parallel. In reality, however, only two outbound calls targeting a given IP address can be pending at a time unless you increase the runtime's default maxconnection setting:
<system.net>
<connectionManagement>
<add address="*" maxconnection="20" />
</connectionManagement>
</system.net>
Other configuration settings can also affect concurrency. For more information, refer to the Knowledge Base article "Contention, Poor Performance, and Deadlocks when You Make Web Service Requests from ASP.NET Applications" (support.microsoft.com/kb/821268).
Even if only one callout executes at a time, AsyncTerraServiceImageGrabber should perform no worse than TerraServiceImageGrabber. And its design is far superior because it uses ASP.NET threads as efficiently as possible.
Asynchronous HTTP Modules
The third asynchronous programming model you can leverage in ASP.NET is the asynchronous HTTP module. An HTTP module is an object that sits in the ASP.NET pipeline, where it can see-and even modify-incoming requests and outgoing responses. Many of the key services in ASP.NET are implemented in the form of HTTP modules, including authentication, authorization, and output caching. You can extend ASP.NET by writing custom HTTP modules and plugging them into the pipeline. And when you do, you should carefully consider whether those HTTP modules should be asynchronous.
Figure 6 contains the source code for a simple, synchronous HTTP module called RequestLogModule, which logs incoming requests in a text file named RequestLog.txt. It creates the file in the site's App_Data directory so users can't browse to it. (Note that the security principal that ASP.NET runs as-ASPNET or Network Service, for example-must have write permission to App_Data for this to work.) The module implements the IHttpModule interface, which is the one and only requirement for an HTTP module. When the module loads, its Init method registers a handler for HttpApplication.PreRequestHandlerExecute events, which fire from the pipeline in each and every request. The event handler opens RequestLog.txt (or creates it if it doesn't already exist) and writes into it a line of text containing pertinent information about the current request, including the time and date that the request arrived, the requestor's user name if the request is authenticated (or the requestor's IP address if authentication is turned off), and the requested URL. The module is registered in the <httpModules> section of web.config, prompting ASP.NET to load it each time the application starts up.
Figure 6 Synchronous HTTP Module
using System;
using System.Web;
using System.IO;
public class RequestLogModule : IHttpModule
{
public void Init (HttpApplication application)
{
application.PreRequestHandlerExecute +=
new EventHandler(OnPreRequestHandlerExecute);
}
public void Dispose () {}
void OnPreRequestHandlerExecute(Object sender, EventArgs e)
{
HttpApplication app = (HttpApplication)sender;
DateTime time = DateTime.Now;
using(StreamWriter writer = new StreamWriter(
app.Server.MapPath("~/App_Data/RequestLog.txt"), true))
{
string line = String.Format(
"{0,10:d} {1,11:T} {2, 32} {3}",
time, time,
app.User.Identity.IsAuthenticated ?
app.User.Identity.Name :
app.Request.UserHostAddress,
app.Request.Url);
writer.WriteLine (line);
}
}
}
The problem with RequestLogModule is twofold. First, it performs file I/O in each and every request. Second, it uses a request-processing thread to perform the I/O-a thread that could otherwise be used to service additional incoming requests. This module, simple as it is, imposes a penalty on throughput. While you could mitigate the delay by batching the file I/O operations, a better approach is to make the module asynchronous (or better yet, batch the file I/O and make the module asynchronous).
Figure 7 shows the asynchronous version of RequestLogModule. Called AsyncRequestLogModule, it performs exactly the same work, but returns the thread assigned to the request to the thread pool before writing to the file. When the write completes, a new thread is borrowed from the thread pool and used to finish the request.
Figure 7 Asynchronous HTTP Module
using System;
using System.Web;
using System.IO;
using System.Threading;
using System.Text;
public class AsyncRequestLogModule : IHttpModule
{
private FileStream _file;
private static long _position = 0;
private static object _lock = new object();
public void Init (HttpApplication application)
{
application.AddOnPreRequestHandlerExecuteAsync (
new BeginEventHandler (BeginPreRequestHandlerExecute),
new EndEventHandler (EndPreRequestHandlerExecute)
);
}
IAsyncResult BeginPreRequestHandlerExecute (Object source,
EventArgs e, AsyncCallback cb, Object state)
{
HttpApplication app = (HttpApplication)source;
DateTime time = DateTime.Now;
string line = String.Format(
"{0,10:d} {1,11:T} {2, 32} {3}\r\n",
time, time,
app.User.Identity.IsAuthenticated ?
app.User.Identity.Name :
app.Request.UserHostAddress,
app.Request.Url);
byte[] output = Encoding.ASCII.GetBytes(line);
lock (_lock)
{
_file = new FileStream(
HttpContext.Current.Server.MapPath(
"~/App_Data/RequestLog.txt"),
FileMode.OpenOrCreate, FileAccess.Write,
FileShare.Write, 1024, true);
_file.Seek(_position, SeekOrigin.Begin);
_position += output.Length;
return _file.BeginWrite(output, 0, output.Length, cb, state);
}
}
void EndPreRequestHandlerExecute (IAsyncResult ar)
{
_file.EndWrite(ar);
_file.Close();
}
public void Dispose () {}
}
What makes AsyncRequestLogModule asynchronous? Its Init method calls HttpApplication.AddOnPreRequestHandlerExecuteAsync in order to register begin and end methods for PreRequestHandlerExecute events. The HttpApplication class features other AddOn methods for other per-request events. For example, an HTTP module can call AddOnBeginRequestAsync to register async handlers for BeginRequest events. AsyncRequestLogModule's BeginPreRequestHandlerExecute method uses the framework's FileStream.BeginWrite method to begin an asynchronous write. The moment BeginPreRequestHandlerExecute returns, the thread goes back into the thread pool.
AsyncRequestLogModule contains some thread synchronization logic that merits special mention. It's possible-likely even-that multiple requests running on multiple threads will write to the log file at the same time. To ensure that concurrent writes don't overwrite each other, AsyncRequestLogModule stores the position in the file for the next write in a private field shared by all module instances (_position). Before each call to BeginWrite, the module reads the position from the field and updates the field to point to the first byte following what's about to be written to the file. Logic that reads and updates _position is wrapped in a lock statement so that no more than one thread can execute it at a time. That prevents one thread from reading the position before another gets the chance to update it.
Now the bad news. For BeginWrite not to use another thread from the pool, the isAsync parameter to FileStream's constructor must be set to true, as I've done in my example. However, a little-known consequence of using FileStream.BeginWrite to initiate an asynchronous write is that there's no guarantee the write will actually be asynchronous, even if you've requested asynchronous operation. Windows® reserves the right to perform asynchronous file I/O synchronously if it believes that synchronous I/O will be faster. For more information, read the Knowledge Base article at support.microsoft.com/kb/156932. The good news is that if Windows writes to the request log synchronously, the writes are, in theory, performed so quickly that they have minimal impact on the scalability of the host application.
Conclusion
Asynchronous programming is an excellent way to build more scalable applications by using the ASP.NET thread pool as efficiently as possible. In my travels, I rarely see ASP.NET developers utilize asynchronous programming models, in part because they simply don't know that these models exist. Don't let sparse documentation stand in your way; start thinking asynchronously today, and you'll be building better applications tomorrow.
Be aware that the downloadable sample code accompanying this article comes in both C# and Visual Basic® versions. I frequently get e-mail asking for Visual Basic versions of the samples. This time, you don't have to ask; they're already there!
Send your questions and comments for Jeff to wicked@microsoft.com.
Jeff Prosise is a contributing editor to MSDN Magazine and the author of several books, including Programming Microsoft .NET (Microsoft Press, 2002). He's also a cofounder of Wintellect, a software consulting and education firm that specializes in .NET.