For many ASP.NET-based web sites, an effective way to improve site performance and scalability is by thoroughly addressing issues related to threads and sessions.
In this chapter, I’ll cover the following:
- The very negative impact of using synchronous pages when you make out-of-process or off-server calls
- Improving the scalability of your site by using asynchronous pages and background worker threads
- A brief overview of locking as it applies to asynchronous programming
- The scalability impact of the default session state provider, why it’s best to avoid session state if you can, and what the alternatives are
- An approach to building a customized and highly scalable session state provider
I’ve noticed that many large sites end up spending a lot of effort optimizing their systems in the wrong places.
As an example, let’s say that you’re building a one-page site that should support 1,200 simultaneous users, with a response time of one second or less, and you have plans to scale-up later on to 120,000 users.
During load testing, you reach your maximum acceptable response time after reaching 120 simulated users on a single CPU, and the CPU is 24% busy. As you increase the load, you find that CPU use stays the same, but response time increases. By the time you reach 1,200 users, response time is ten seconds—ten times what it was at 120 users.
At this stage, you will need ten CPU cores (best case, assuming linear scaling) to support your target user load and performance goals in the short term, and 1,000 cores in the long term.
To determine to what extent you can to optimize this scenario, you measure the time it takes to process each phase of a single request on an unloaded machine. The results are in Figure 5-1.
You find that receiving 0.5KB at 128Kbps takes 5ms, obtaining the needed data from the database takes 77ms, generating the response HTML takes 2ms, and sending the 5KB response at 384Kbps takes 16ms.
When faced with data like this, the first place many developers would look for improvements is the slowest part of the process, which in this case is the database access. In some environments, the database is a black box, so you can’t tune it. When you can, the usual approach is to put a lot of emphasis on query optimization. Although that certainly can be helpful, it often doesn’t completely solve the problem. In later chapters, I’ll show some reasons why that’s the case and what you can do about it. For this example, let’s assume the queries are already fully tuned.
The next largest chunks of time are spent receiving the request and sending the response. A typical initial reaction of developers is that “you can’t do anything about the client’s data transmission rates, so forget about the request and response times.” As I’ve shown in Chapter 2, that clearly is not the whole story.
That leaves the time to generate the HTML, which in this case is only 2 percent of the total request-processing time. Because that part of the application appears to developers to be most readily under their control, optimizing the time spent there is often where they end up spending their performance improvement efforts. However, even if you improve that time by 50 percent, down to 1ms, the overall end-to-end improvement seen by end users may only be 1 percent. In this example, CPU use would decline to 12 percent, but you would still need the same total number of CPUs; it doesn’t improve scalability.
I would like to suggest looking at this problem in a much different way. In a correctly designed architecture, the CPU time spent to process a request at the web tier should not be a primary factor in overall site performance or scalability. In the previous example, an extra 2ms one way or the other won’t be noticeable by an end user.
In this example, and often in the real world as well, reducing the CPU time spent by the web tier in generating the pages reduces the CPU load on each machine, but it doesn’t improve throughput or reduce the number of machines you need.
What’s happening in the example is that the site’s throughput is limited by the IIS and ASP.NET thread pools. By default, there are 12 worker threads per CPU. Each worker processes one request at a time, which means 12 requests at a time per CPU. If clients present new requests when all of the worker threads are busy, they are queued.
Since each request takes 100ms to process from end to end, one thread can process ten requests per second. With 12 requests at a time, that becomes 120 requests per second. With 2ms of CPU time per request, 120 * 0.002 = 0.24 or 24% CPU use.
The solution to scalability in this case is to optimize thread use, rather than minimizing CPU use. You can do that by allowing each worker thread to process more than one request at a time, using asynchronous database requests. Using async requests should allow you either to reach close to 100% CPU use, or to push your scalability issues to another tier, such as the database. At 100% CPU use, you would only need one quarter of the CPUs you did at the start.
Adding more worker threads can help in some cases. However, since each thread has costs associated with it (startup time, memory, pool management, context switch overhead), that’s only effective up to a point.
In this example, caching helps if you can use it to eliminate the database request. Threads come into play when you can’t. When CPU use per server averages 70 to 80+ percent under peak load, then it tends to become a determining factor for how many CPUs you need. At that stage, it makes sense to put effort into optimizing the CPU time used by the application—but to minimize the number of servers you need, not to improve performance from the user’s perspective.
Of course, there are cases where CPU use is the dominant factor that you should address first, but once a site is in production, those cases tend to be the exception and not the rule. Developers and testers tend to catch those cases early. Unfortunately, threading issues often don’t appear until a site goes into production and is under heavy load.
Low CPU use per server is one reason some sites have found that using virtual machines (VMs) or IIS web gardens can improve their overall throughput. Unfortunately, VMs add overhead and can complicate operations, deployment, and maintenance. You should weigh those options against the effort to modify your applications to improve thread use through async requests and related optimizations covered in this chapter.
As I discussed in Chapter 4, HTTP requests processed by IIS go through a series of states on the way to generating a response. Similarly, ASP.NET pages also go through a series of states. As with IIS, the runtime generates events at each state that you can register a handler for and take action on. See Figure 5-2 for a diagram of the standard synchronous page life cycle and associated events.
The HTTP request enters the page-processing pipeline at the top of the figure, when IIS starts to execute the Page Handler
(see also Figure 4-2). As the processing progresses, the runtime moves from one state to another, calling all registered event handlers as it goes. In the synchronous case, a single thread from the ASP.NET thread pool does all the processing for the page.
Note The Render
phase is not an event. All pages and controls have a Render()
method that’s responsible for generating the output that will be sent to the client.
For Init
and Unload
, the runtime fires the events for child controls before the parent (bottom–up), so the Page
events fire last. For Load
and PreRender
, the runtime fires events in the parent followed by child events (top–down), so Page
events fire first. The other events in the figure above, except for Control Events, only exist at the Page
level, not in controls. The runtime treats master pages as child controls of the Page
.
DataBind
is an optional event that happens after PreRender
either when you set a DataSourceID
declaratively, or when you call DataBind()
.
If you have code blocks in your markup (using <%= %>
), the runtime executes that code during the Render
phase. That’s why you can’t set control properties using code blocks; controls are instantiated, including setting their initial properties, at the beginning of the page life cycle, whereas Render
happens at the end.
Instead of the usual serial and synchronous approach, it’s possible to configure a page to run asynchronously. For asynchronous pages, ASP.NET inserts a special “async point” into the page life cycle, after the PreRender
event. One thread executes the part of the life cycle before the async point and starts the async requests. Then the same thread, or possibly a different one from the thread pool, executes the rest of the life cycle after the async point. See Figure 5-2.
Let’s put together a test case to demonstrate how the application thread pool processes both sync and async pages.
Add a new web form in Visual Studio, and call it sql-sync.aspx
. Keep the default markup, and use the following code-behind:
using System;
using System.Data.SqlClient;
using System.Web.UI;
public partial class sql_sync : Page
{
public const string ConnString = "Data Source=.;Integrated Security=True";
protected void Page_Load(object sender, EventArgs e)
{
using (SqlConnection conn = new SqlConnection(ConnString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:01'", conn))
{
cmd.ExecuteNonQuery();
}
}
}
}
The code connects to SQL Server on the local machine and issues a WAITFOR DELAY
command that waits for one second.
Note I’m using a connection string that’s compatible with a local default instance of a “full” edition of SQL Server, such as Developer, Enterprise or Standard. If you’re using SQL Server Express, which works with most (but not all) of the examples in the book, the Data Source
field should be .SQLEXPRESS
. In both cases, the dot is shorthand for localhost
or your local machine name. I’m showing connection strings in-line for clarity. In a production application, you should usually store them in web.config
or a related configuration file.
You don’t need to specify which database to connect to, since you aren’t accessing any tables or other securables.
Next, create another page called sql-async.aspx
. Change the Page
directive in the markup file to include Async="true"
:
<%@ Page Language="C#" Async="true" AutoEventWireup="true"
CodeFile="sql-async.aspx.cs" Inherits="sql_async" %>
That tells the runtime that this page will be asynchronous, so it will create the async point as in Figure 5-3.
Next, create the code-behind as follows, using the asynchronous programming model (APM):
using System;
using System.Data.SqlClient;
using System.Web;
using System.Web.UI;
public partial class sql_async : Page
{
public const string ConnString = "Data Source=.;Integrated Security=True;Async=True";
Here you are including Async=True
in the connection string to inform SQL Server that you will be issuing asynchronous queries. Using async queries requires a little extra overhead, so it’s not the default.
protected void Page_Load(object sender, EventArgs e)
{
PageAsyncTask pat = new PageAsyncTask(BeginAsync, EndAsync, null, null, true);
this.RegisterAsyncTask(pat);
}
In the Page_Load()
method, you create a PageAsyncTask
object that refers to the BeginAsync()
method that the runtime should call to start the request and the EndAsync()
method that it should call when the request completes. Then you call RegisterAsyncTask()
to register the task. The runtime will then call BeginAsync()
before the PreRenderComplete
event, which is fired before the markup for the page is generated.
private IAsyncResult BeginAsync(object sender, EventArgs e,
AsyncCallback cb, object state)
{
SqlConnection conn = new SqlConnection(ConnString);
conn.Open();
SqlCommand cmd = new SqlCommand ("WAITFOR DELAY '00:00:01'", conn);
IAsyncResult ar = cmd.BeginExecuteNonQuery(cb, cmd);
return ar;
}
The BeginAsync()
method opens a connection to SQL Server and starts the WAITFOR DELAY
command by calling BeginExecuteNonQuery()
. This is the same database command that was used in the synchronous page, but BeginExecuteNonQuery()
ddoesn’t wait for the response from the database like ExecuteNonQuery()
does.
private void EndAsync(IAsyncResult ar)
{
using (SqlCommand cmd = (SqlCommand)ar.AsyncState)
{
using (cmd.Connection)
{
int rows = cmd.EndExecuteNonQuery(ar);
}
}
}
}
The runtime will call EndAsync()
when the async database call completes. EndAsync()
calls EndExecuteNonQuery()
to complete the command. You have two using
statements that ensure Dispose()
is called on the SqlConnection
and SqlCommand
objects.
Keep in mind when writing async pages that it doesn’t help to perform CPU–intensive operations asynchronously. The goal is to give up the thread when it would otherwise be idle waiting for an operation to completeso that it can do other things. If the thread is busy with CPU–intensive operations and does not go idle, then using an async task just introduces extra overhead that you should avoid.
Starting in .NET 4.5, you have the option of implementing async pages using the task–based asynchronous pattern (TAP), which results in code that’s easier to read, write, and maintain (see sql-async2.aspx.cs
):
using System;
using System.Data.SqlClient;
using System.Threading.Tasks;
using System.Web.UI;
public partial class sql_async2 : Page
{
public const string ConnString = "Data Source=.;Integrated Security=True;Async=True";
protected async void Page_PreRender(object sender, EventArgs e)
{
using (SqlConnection conn = new SqlConnection(ConnString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:01'", conn))
{
await Task.Factory.FromAsync<int>(cmd.BeginExecuteNonQuery,
cmd.EndExecuteNonQuery, null);
}
}
}
}
The connection string and the Page
directive still require Async=True
. Instead of using the async Begin
and End
methods directly, you add the async
keyword to the Page_PreRender
event handler, and structure the database calls nearly as you would for the synchronous case. However, instead of using ExecuteNonQuery()
, you call Task.Factory.FromAsync()
with the names of the Begin
and End
methods, and prefix the call with the await
keyword. That will start the async operation and create a hidden, in–place continuation that the runtime will call when it completes.
With this approach, note that the thread returns to the thread pool right after starting the request, so the runtime won’t execute any code after the line with the await
keyword until after the async request completes.
One difference between using TAP and APM for async pages is that TAP starts the async operation right away, whereas by default APM queues the async request and doesn’t start it until the async point, right after the PreRender
event.
For the tests below to work as I describe, use Windows Server 2008 or 2008 R2. Threading behaves differently with IIS on Vista or Windows 7, which support only either three or ten simultaneous requests, depending on the edition you’re using.
Add the new pages to a web site that’s running under IIS (not IIS Express or Cassini), and check to make sure they’re working.
Let’s use the same load test tool as in Chapter 3, WCAT. Create the configuration file as follows in the WCAT Controller folder, and call it c2.cfg
:
Warmuptime 5s
Duration 30s
CooldownTime 0s
NumClientMachines 1
NumClientThreads 100
The test will warm up for 5 seconds and run for 30 seconds, using a single client process with 100 threads.
Let’s test the synchronous case first. Create the test script in the same folder, and call it s2.cfg
:
SET Server = "localhost"
SET Port = 80
SET Verb = "GET"
SET KeepAlive = true
NEW TRANSACTION
classId = 1
Weight = 100
NEW REQUEST HTTP
URL = "/sql-sync.aspx"
You can of course adjust the server name or port number if needed.
You are now ready to run the first test. Open one window with the WCAT Controller and another with the WCAT Client. In the controller window, start the controller as follows:
wcctl-a localhost -c c2.cfg -s s2.cfg
In the client window, start the client:
wcclient localhost
I used a virtual machine for the test, configured as a single CPU socket with four cores, running Windows Server 2008 R2 x64 and IIS 7.5 with .NET 4.5. I ran WCAT from the host, with Windows 7 Ultimate x64. I started the test right after restarting the AppPool. Here are the results, as shown in the client window at the 10, 20, and 30-second points:
Total 200 OK : 133 ( 13/Sec)
Avg. Response Time (Last) : 8270 MS
Total 200 OK : 370 (23/Sec)
Avg. Response Time (Last) : 6002 MS
Total 200 OK : 701 (23/Sec)
Avg. Response Time (Last) : 4680 MS
Even though the only thing the page does is to sleep for one second, the server is able to deliver just 13 to 23 requests per second, and the average response time ranges from 8.3 to 4.7 seconds.
Next, change the URL in s2.cfg
to refer to one of the async pages, recycle the AppPool, and repeat the test. Here are the results:
Total 200 OK : 1000 (100/Sec)
Avg. Response Time (Last) : 1001 MS
Total 200 OK : 2000 (100/Sec)
Avg. Response Time (Last) : 1001 MS
Total 200 OK : 3000 (100/Sec)
Avg. Response Time (Last) : 1002 MS
The number of requests per second has increased by a factor of four, to 100 per second, and the response time has decreased to one second—which is what you would expect with 100 request threads each running a task that sleeps for one second.
Note If you increase NumClientThreads
above 100 for the async test case, you will find that the load test slows down again. This happens because the SQL Server client API by default supports a maximum of 100 simultaneous connections per unique connection string. Beyond that, connection requests to the database are queued. You can increase the maximum by setting Max Pool Size
in the connection string.
Why is the synchronous case so much slower?
In the synchronous test case, one thread can handle only one request at a time. Since there were 13 requests per second at the 10-second mark, and since each request ties up a thread the whole time it runs, you can tell that there were 13 threads, or roughly two three per CPU core. By 20 seconds, there were 23 requests per second, which means 23 threads, or roughly six per core. The runtime added more threads when it detected requests that were being queued.
Since creating new threads is a relatively expensive operation, the runtime adds them slowly, at a maximum rate of about two per second. This can cause accentuated performance problems on web sites with relatively bursty traffic, since the thread pool may not grow quickly enough to eliminate request queuing.
In the async case, after a thread starts the async operation, the thread returns to the pool, where it can go on to process other requests. That means it takes far fewer threads to process even more requests.
You can tune the thread pool in IIS 7 and 7.5 by editing the Aspnet.config
file, which is located in C:WindowsMicrosoft.NETFrameworkv4.0.30319
. Here’s an example:
<configuration>
. . .
<system.web>
<applicationPool maxConcurrentRequestsPerCPU="5000"
maxConcurrentThreadsPerCPU="0"
requestQueueLimit="5000" />
</system.web>
</configuration>
The parameters in the example are the same as the defaults for .NET 4.5. After updating the file, you will need to restart IIS in order for the changes to take effect. If you rerun the previous tests, you should see that they both produce the same results.
You can also get or set two of these parameters programmatically, typically from Application_Start
in global.asax
:
using System.Web.Hosting;
HostingEnvironment.MaxConcurrentRequestsPerCPU = 5000;
HostingEnvironment.MaxConcurrentThreadsPerCPU = 0;
You can adjust the limits on the number of concurrent requests per CPU with the maxConcurrentRequestsPerCPU
parameter, and the threads per CPU with the maxConcurrentThreadsPerCPU
parameter. A value of 0
means that there is no hard limit. One parameter or the other can be set to 0
, but not both. Both can also have nonzero values. Enforcing thread limits is slightly more expensive than enforcing request limits.
The number of concurrent requests and concurrent threads can only be different when you’re using async pages. If your pages are all synchronous, each request will tie up a thread for the duration of the request.
Note In addition to the concurrent request limits that IIS imposes, the http.sys
driver has a separately configured limit, which is set to 1,000 connections by default. If you reach that limit, http.sys
will return a 503 Service Unavailable
error code to clients. If you see those errors in your IIS logs (or if they are reported by users), consider increasing the Queue Length parameter in AppPool Advanced Settings.
For most applications, the defaults in .NET 4.5 work fine. However, in some applications, serializing requests to some degree at the web tier by reducing maxConcurrentRequestsPerCPU
or by using maxConcurrentThreadsPerCPU
instead can be desirable in order to avoid overloading local or remote resources. For example, this can be the case when you make heavy use of web services or when the number of threads in the thread pool becomes excessive. In mixed–use scenarios, you may find that it’s better to implement programmatic limits on resource use, rather than trying to rely entirely on the runtime.
In sites whose primary off-box calls are to SQL Server, it’s usually better to allow many simultaneous requests at the web tier, and let the database handle queuing and serializing the requests. SQL Server can complete some requests quickly, and in a large-scale environment, there might be multiple partitioned database servers. In those cases, the web tier just doesn’t have enough information for a policy of substantially limiting the number of simultaneous requests to be an effective performance-enhancing mechanism.
Since increasing the maximum number of concurrent requests or threads won’t help the sync test case, if you have a large site with all-sync requests, you might be wondering whether there’s anything you can do to improve throughput while you’re working on converting to async. If your application functions correctly in a load-balanced arrangement and you have enough RAM on your web servers, then one option is to configure your AppPools to run multiple worker processes as a web garden.
In the previous sync test case, if you configure the AppPool to run two worker processes, throughput will double. One price you pay for multiple workers is increased memory use; another is increased context switch overhead. Data that can be shared or cached will need to be loaded multiple times in a web garden scenario, just as it would if you were running additional web servers.
While developing async pages, you will often run into cases where you need to execute multiple tasks on a single page, such as several database commands. Some of the tasks may not depend on one another and so can run in parallel. Others may generate output that is then consumed by subsequent steps. From a performance perspective, it’s usually best to do data combining in the database tier when you can. However, there are also times where that’s not desirable or even possible.
The first solution to this issue works when you know in advance what all the steps will be and the details of which steps depend on which other steps. The fifth (last) argument to the PageAsyncTask
constructor is the executeInParallel
flag. You can register multiple PageAsyncTask
objects with the page. When you do, the runtime will start them in the order they were registered. Tasks that have executeInParallel
set to true
will be run at the same time. When the flag is set to false
, those tasks will run one at a time, in a serialized fashion.
For example, let’s say that you have three tasks, the first two of which can run at the same time, but the third one uses the output of the first two, so it shouldn’t run until they are complete (see async-parallel.aspx
):
protected void Page_Load(object sender, EventArgs e)
{
PageAsyncTask pat = new PageAsyncTask(BeginAsync1, EndAsync1, null, null, true);
this.RegisterAsyncTask(pat);
pat = new PageAsyncTask(BeginAsync2, EndAsync2, null, null, true);
this.RegisterAsyncTask(pat);
pat = new PageAsyncTask(BeginAsync3, EndAsync3, null, null, false);
this.RegisterAsyncTask(pat);
}
The executeInParallel
flag is set to true
for the first two tasks, so they run simultaneously. It’s set to false
for the third task, so the runtime doesn’t start it until the first two complete.
The fourth argument to the PageAsyncTask
constructor is a state object. If you provide a reference to one, it will be passed to your BeginEventHandler
. This option can be helpful if the BeginEventHandler
is in a different class than your page, such as in your data access layer (DAL).
The other approach to this issue relies on the fact that the runtime won’t advance to the next state in the page-processing pipeline until all async tasks are complete. That’s true even if you register those tasks during the processing of other async tasks. However, in that case, you need to take one extra step after registering the task, which is to start it explicitly.
The following builds on the previous sql-async.aspx
example (see async-seq.aspx
):
private void EndAsync(IAsyncResult ar)
{
using (SqlCommand cmd = (SqlCommand)ar.AsyncState)
{
using (cmd.Connection)
{
int rows = cmd.EndExecuteNonQuery(ar);
}
}
PageAsyncTask pat = new PageAsyncTask(BeginAsync2, EndAsync2, null, null, true);
this.RegisterAsyncTask(pat);
this.ExecuteRegisteredAsyncTasks();
}
The call to ExecuteRegisteredAsyncTasks()
will start any tasks that have not already been started. It’s not required for tasks that you’ve registered before the end of PreRender
event processing. This approach also allows the tasks to be conditional or to overlap in more complex ways than the executeInParallel
flag allows.
You can also call ExecuteRegisteredAsyncTasks()
earlier in the page life cycle, which will cause the runtime to execute all registered tasks at that time, rather than at the async point. Tasks are called only once, regardless of how many times you call ExecuteRegisteredAsyncTasks()
.
You can also use TAP to execute tasks in parallel (see async-parallel2.aspx.cs
):
using System;
using System.Data.SqlClient;
using System.Threading.Tasks;
using System.Web.UI;
public partial class async_parallel2 : Page
{
public const string ConnString = "Data Source=.;Integrated Security=True;Async=True";
protected void Page_PreRender(object sender, EventArgs e)
{
Task1();
Task2();
}
private async void Task1()
{
using (SqlConnection conn = new SqlConnection(ConnString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:01'", conn))
{
await Task.Factory.FromAsync<int>(cmd.BeginExecuteNonQuery,
cmd.EndExecuteNonQuery, null);
}
using (SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:02'", conn))
{
await Task.Factory.FromAsync<int>(cmd.BeginExecuteNonQuery,
cmd.EndExecuteNonQuery, null);
}
}
}
private async void Task2()
{
using (SqlConnection conn = new SqlConnection(ConnString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:00:03'", conn))
{
await Task.Factory.FromAsync<int>(cmd.BeginExecuteNonQuery,
cmd.EndExecuteNonQuery, null);
}
}
}
}
Task1()
will start the first SQL command and then return, and Task2()
will then start the second command in parallel. After the first command in Task1()
completes, the second one will be called. By using slightly different parameters for each WAITFOR DELAY
command, you can easily follow the sequence of events with SQL Profiler.
As the third parameter in the PageAsyncTask
constructor, you can pass a delegate that the runtime will call if the async request takes too long to execute:
PageAsyncTask pat = new PageAsyncTask(BeginAsync, EndAsync, TimeoutAsync, null, true);
You can set the length of the timeout in the Page
directive in your markup file:
<%@ Page AsyncTimeout="30" . . . %>
The value of the AsyncTimeout
property sets the length of the timeout in seconds. However, you can’t set a separate timeout value for each task; you can set only a single value that applies to all of them.
You can set a default value for the async timeout in web.config
:
<system.web>
<pages asyncTimeout="30" . . . />
. . .
</system.web>
You can also set the value programmatically:
protected void Page_Load(object sender, EventArgs e)
{
this.AsyncTimeout = TimeSpan.FromSeconds(30);
. . .
}
Here’s an example that forces a timeout (see async-timeout.aspx
):
using System;
using System.Data.SqlClient;
using System.Web.UI;
public partial class async_timeout : Page
{
public const string ConnString = "Data Source=.;Integrated Security=True;Async=True";
protected void Page_Load(object sender, EventArgs e)
{
this.AsyncTimeout = TimeSpan.FromSeconds(5);
PageAsyncTask pat = new PageAsyncTask(BeginAsync, EndAsync,
TimeoutAsync, null, true);
RegisterAsyncTask(pat);
}
You set the timeout to five seconds and then create and register the task.
private IAsyncResult BeginAsync(object sender, EventArgs e,
AsyncCallback cb, object state)
{
SqlConnection conn = new SqlConnection(ConnString);
conn.Open();
SqlCommand cmd = new SqlCommand("WAITFOR DELAY '00:01:00'", conn);
IAsyncResult ar = cmd.BeginExecuteNonQuery(cb, cmd);
return ar;
}
The WAITFOR
command waits for one minute, which is longer than the five-second timeout, so the page will display the error message when it runs.
private void EndAsync(IAsyncResult ar)
{
using (SqlCommand cmd = (SqlCommand)ar.AsyncState)
{
using (cmd.Connection)
{
int rows = cmd.EndExecuteNonQuery(ar);
}
}
}
private void TimeoutAsync(IAsyncResult ar)
{
errorLabel.Text = "Database timeout error.";
SqlCommand cmd = (SqlCommand)ar.AsyncState;
cmd.Connection.Dispose();
cmd.Dispose();
}
}
The runtime doesn’t call the end event handler if a timeout happens. Therefore, in the timeout event handler, you clean up the SqlCommand
and SqlConnection
objects that were created in the begin handler. Since you don’t have any code that’s using those objects here like you do in the end handler, you explicitly call their Dispose()
methods instead of relying on using
statements.
Another type of long-running task that’s a good candidate to run asynchronously is calls to web services. As an example, let’s build a page that uses Microsoft’s TerraServer system to get the latitude and longitude for a given city in the United States. First, right-click your web site in Visual Studio, select Add Service Reference, and enter the URL for the WSDL:
http://terraserverusa.com/TerraService2.asmx?WSDL
Click the Go button to display the available services. See Figure 5-3.
Set the Namespace to TerraServer
and click OK to finish adding it.
Next, add a web form called terra1.aspx
.
Set Async="True"
in the Page
directive, and add two <asp:Label>
tags to hold the eventual results:
<%@ Page Async="true" Language="C#" AutoEventWireup="true"
CodeFile="terra1.aspx.cs" Inherits="terra1" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<div>
<asp:Label runat="server" ID="LA" />
<br />
<asp:Label runat="server" ID="LO" />
</div>
</form>
</body>
</html>
Here’s the code-behind (see terra1.aspx.cs
):
using System;
using System.Web.UI;
using TerraServer;
public partial class terra1 : Page
{
protected async void Page_Load(object sender, EventArgs e)
{
var terra = new TerraServiceSoapClient();
Place place = new Place()
{
City = "Seattle",
State = "WA",
Country = "US"
};
var result = await terra.GetPlaceFactsAsync(place);
PlaceFacts facts = result.Body.GetPlaceFactsResult;
this.LA.Text = String.Format("Latitude: {0:0.##}", facts.Center.Lat);
this.LO.Text = String.Format("Longitude: {0:0.##}", facts.Center.Lon);
}
}
Services use the TAP model for async operations, so start by adding the async
keyword to the declaration for Page_Load()
. Create an instance of the TerraServerSoapClient
service proxy object that Visual Studio created for you, along with a Place
object that contains the City
, State
and Country
you want to lookup.
Invoke the web service asynchronously by calling GetPlaceFactsAsync()
with the Place
object as an argument, and await
the response. When the call returns, get the PlaceFacts
object from the results and use it to obtain the latitude and longitude of the specified Place
.
For an asynchronous file I/O example using APM, create a new web form called file1.aspx
. Set Async=True
in the Page
directive and add the same two Labels
to the markup as you did for the web service example earlier.
Here’s the code-behind (see file1.aspx.cs
):
using System;
using System.IO;
using System.Web;
using System.Web.UI;
public partial class file1 : Page
{
private byte[] Data { get; set; }
protected void Page_Load(object sender, EventArgs e)
{
PageAsyncTask pat = new PageAsyncTask(BeginAsync, EndAsync, null, null, true);
RegisterAsyncTask(pat);
}
As before, you create and register the async task. You’re not using a timeout handler here since local file access shouldn’t need it.
private IAsyncResult BeginAsync(object sender, EventArgs e,
AsyncCallback cb, object state)
{
FileStream fs = new FileStream(this.Server.MapPath("csg.png"),
FileMode.Open, FileAccess.Read, FileShare.Read, 4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
this.Data = new byte[64 * 1024];
IAsyncResult ar = fs.BeginRead(this.Data, 0, this.Data.Length, cb, fs);
return ar;
}
To use a FileStream
for asynchronous file I/O, be sure to either set the useAsync
parameter to true
or include the FileOptions.Asynchronous
bit in the FileOptions
flag. For best performance, you should use a buffer size of 1KB or more; I’ve used 4KB in the example. For larger files, you may see a slight performance improvement with buffers up to about 64KB in size. If you know the access pattern for your file (random vs. sequential), it’s a good idea to include the corresponding flag when you create the FileStream
, as a hint that the OS can use to optimize the underlying cache. I’ve specified FileOptions.SequentialScan
to indicate that I will probably read the file sequentially.
To use TAP instead of APM, you can call FileStream.ReadAsync()
to start the read operation and return an awaitable Task
object:
int size = await fs.ReadAsync(this.Data, 0, this.Data.Length);
Tip When you’re reading just a few bytes, async file I/O can be considerably more expensive than synchronous I/O. The threshold varies somewhat, but I suggest a 1KB file size as a reasonable minimum: for files less than 1KB in size, you should prefer synchronous I/O.
private void EndAsync(IAsyncResult ar)
{
using (FileStream fs = (FileStream)ar.AsyncState)
{
int size = fs.EndRead(ar);
this.LA.Text = "Size: " + size;
}
}
}
When the I/O is done, you call EndRead()
to get the number of bytes that were read and then write that value in one of the labels on the page.
The process for async writes is similar. However, in many cases even when you request an async write, the operating system will handle it synchronously. The usual reason is that the OS forces all requests that extend files to happen synchronously. If you create or truncate a file and then write it sequentially, all writes will be extending the file and will therefore be handled synchronously. If the file already exists, you can get around that by opening it for writing and, rather than truncating it, use FileStream.SetLength()
to set the length of the file as early as you can. That way, if the old file is as long as or longer than the new one, all writes will be asynchronous. Even if the file doesn’t already exist, calling FileStream.SetLength()
as early as you can is still a good idea, since it can allow the operating system to do certain optimizations, such as allocating the file contiguously on disk.
In addition, reads and writes to compressed filesystems (not individual compressed files) and to files that are encrypted with NTFS encryption are forced by the OS to be synchronous.
Tip During development, it’s a good practice to double-check that the OS is really executing your calls asynchronously. With APM, you can do that by checking the IAsyncResult.CompletedSynchronously
flag after you issue the Begin
request.
Following what by now I hope is a familiar pattern, let’s walk through an example of how to execute a web request asynchronously using TAP. First, create a new web form called webreq1.aspx
. Make the same changes to the markup file that you did for the previous examples: set the Async
flag in the Page
directive, and add the <asp:Label>
controls.
Here’s the code-behind (see webreq1.aspx.cs
):
using System;
using System.Net;
using System.Text;
using System.Web.UI;
public partial class webreq1 : Page
{
protected async void Page_Load(object sender, EventArgs e)
{
WebRequest request = WebRequest.Create("http://www.apress.com/");
WebResponse response = await request.GetResponseAsync();
StringBuilder sb = new StringBuilder();
foreach (string header in response.Headers.Keys)
{
sb.Append(header);
sb.Append(": ");
sb.Append(response.Headers[header]);
sb.Append("<br/>");
}
this.LO.Text = sb.ToString();
}
}
Add the async
keyword to the Page_Load()
method. Create a WebRequest
object, and await
a call to its GetResponseAsync()
method. After the call returns, collect the response header keys and values into a StringBuilder
, along with a <br/>
between lines, and display the resulting string in one of the labels on the page.
The WebRequest
and WebResponse
objects don’t implement IDisposable
, so you don’t need to call Dispose()
as you did in the other examples.
Another approach to offloading ASP.NET worker threads is to defer activities that might take a long time. One way to do that is with a background worker thread. Rather than performing the task in-line with the current page request, you can place the task in a local queue, which a background worker thread then processes.
Background worker threads are particularly useful for tasks where you don’t require confirmation that they’ve executed on the current page before returning to the user and where a small probability that the task won’t be executed is acceptable, such as if the web server were to crash after the request was queued but before the task was executed. For example, logging can fall in this category. Service Broker is useful for longer tasks that you can’t afford to skip or miss, such as sending an e-mail or recomputing bulk data of some kind. I will cover Service Broker in Chapter 8.
ASP.NET does provide ThreadPool.QueueUserWorkItem()
for executing work items in the background. However, I don’t recommend using it in web applications for two reasons. First, it uses threads from the same thread pool that your pages use and is therefore competing for that relatively scarce resource. Second, multiple threads can execute work items. One of the things that I like to use a background thread for is to serialize certain requests. Since the standard ThreadPool
is a shared object whose configuration shouldn’t be adjusted to extremes, task serialization isn’t possible with QueueUserWorkItem()
without using locks, which would cause multiple threads to be blocked.
Similarly, the .NET Framework provides a way to asynchronously execute delegates, using BeginInvoke()
. However, as earlier, the threads used in this case also come from the ASP.NET thread pool, so you should avoid using that approach too.
There is a fundamental difference between native asynchronous I/O and processing I/O requests synchronously in background threads (so they appear asynchronous). With native async, a single thread can process many I/O requests at the same time. Doing the same thing with background threads requires one thread for each request. Threads are relatively expensive to create. Native async, as you’ve been using in the examples, is therefore much more efficient in addition to not putting an extra la load on the worker thread pool, which is a limited resource.
C# USING AND LOCK STATEMENTS
Here’s a detailed example of using a background thread, which demonstrates a number of key principles for async programming, such as locks (monitors), semaphores, queues, and signaling between threads. The goal of the code is to allow multiple foreground threads (incoming web requests) to queue requests to write logging information to the database in a background worker thread.
The code supports submitting logging requests to the worker thread in batches, rather than one at a time, for reasons that will become clear later in the book.
See App_CodeRequestInfo.cs
:
namespace Samples
{
public class RequestInfo
{
public string Page { get; private set; }
public RequestInfo()
{
this.Page = HttpContext.Current.Request.Url.ToString();
}
}
}
The RequestInfo
object encapsulates the information that you will want to write to the database later. In this case, it’s just the URL of the current page.
See App_CodeWorkItem.cs
:
namespace Samples
{
public enum ActionType
{
None = 0,
Add = 1
}
The ActionType
eenum
defines the various actions that the background worker thread will perform. I use None
as a placeholder for an unassigned value; it is not valid for a queued work item.
public class WorkItem
{
private static Queue<WorkItem> queue = new Queue<WorkItem>();
private static Semaphore maxQueueSemaphore =
new Semaphore(MaxQueueLength, MaxQueueLength);
private static Object workItemLockObject = new Object();
private static WorkItem currentWorkItem;
private static Thread worker;
public delegate void Worker();
The WorkItem
class manages a collection of requests for work to be done, along with a static Queue
of WorkItems
.
You use a Semaphore
to limit how many WorkItem
objects can be queued. When a thread tries to queue a WorkItem
, if the queue is full, the thread will block until the number of items in the queue drops below MaxQueueLength
. You apply a lock to workItemLockObject
to serialize access to currentWorkItem
, in order to allow multiple threads to enqueue requests before you submit the WorkItem
to the background worker thread.
public ActionType Action { get; set; }
public ICollection<RequestInfo> RequestInfoList { get; private set; }
public static int MaxQueueLength
{
get { return 100; }
}
public int Count
{
get { return this.RequestInfoList.Count; }
}
public static int QueueCount
{
get { return queue.Count; }
}
public WorkItem(ActionType action)
{
this.Action = action;
this.RequestInfoList = new List<RequestInfo>();
}
The constructor stores the specified ActionType
and creates a List
to hold RequestInfo
objects. Using a List
maintains the order of the requests.
private void Add(RequestInfo info)
{
this.RequestInfoList.Add(info);
}
The Add()
method adds a RequestInfo
object to the end of RequestInfoList
.
private void Enqueue()
{
if (maxQueueSemaphore.WaitOne(1000))
{
lock (queue)
{
queue.Enqueue(this);
Monitor.Pulse(queue);
}
}
else
{
EventLog.WriteEntry("Application",
"Timed-out enqueueing a WorkItem. Queue size = " + QueueCount +
", Action = " + this.Action, EventLogEntryType.Error, 101);
}
}
The Enqueue()
method adds the current WorkItem
to the end of the Queue
and signals the worker thread. You write an error to the Windows event log if the access to the Semaphore
times out.
This method waits up to 1,000ms to enter the semaphore. If successful, the semaphore’s count is decremented. If the count reaches zero, then future calls to WaitOne()
will block until the count is incremented by calling Release()
from Dequeue()
.
After entering the semaphore, obtain a lock on the queue
object since Queue.Enqueue()
is not thread safe. Next, save the current WorkItem
in the queue
. Then call Monitor.Pulse()
to signal the worker thread that new work is available in the queue.
public static void QueuePageView(RequestInfo info, int batchSize)
{
lock (workItemLockObject)
{
if (currentWorkItem == null)
{
currentWorkItem = new WorkItem(ActionType.Add);
}
currentWorkItem.Add(info);
if (currentWorkItem.Count >= batchSize)
{
currentWorkItem.Enqueue();
currentWorkItem = null;
}
}
}
The QueuePageView()
method starts by getting a lock on workItemLockObject
to serialize access to currentWorkItem
. If currentWorkItem
is null
, then create a new WorkItem
with a type of ActionType.Add
. After adding the given RequestInfo
object to the List
held by the WorkItem
, if the number of objects in that List
is equal to the specified batchSize
, then the WorkItem
is enqueued to the worker thread.
public static WorkItem Dequeue()
{
lock (queue)
{
for (;;)
{
if (queue.Count > 0)
{
WorkItem workItem = queue.Dequeue();
maxQueueSemaphore.Release();
return workItem;
}
Monitor.Wait(queue);
}
}
}
The worker thread uses the Dequeue()
method to obtain the next WorkItem
from the Queue
. First, lock queue
to serialize access. If the queue has anything in it, then Dequeue()
the next item, Release()
the semaphore, and return the WorkItem
. Releasing the semaphore will increment its count. If another thread was blocked with the count at zero, it will be signaled and unblocked.
If the queue is empty, then the code uses Monitor.Wait()
to release the lock and block the thread until the Enqueue()
method is called from another thread, which puts a WorkItem
in the queue and calls Monitor.Pulse()
. After returning from the Wait
, the code enters the loop again at the top.
public static void Init(Worker work)
{
lock (workItemLockObject)
{
if (worker == null)
worker = new Thread(new ThreadStart(work));
if (!worker.IsAlive)
worker.Start();
}
}
}
The Init()
method obtains a lock on the workItemLockObject
to serialize the thread startup code, ensuring that only one worker thread is created. Create the worker thread with the entry point set to the provided Worker
delegate and then start the thread.
public static void Work()
{
try
{
for (;;)
{
WorkItem workItem = WorkItem.Dequeue();
switch (workItem.Action)
{
case ActionType.Add:
The code that’s executed by the worker thread starts with a loop that calls Dequeue()
to retrieve the next WorkItem
. Dequeue()
will block if the queue is empty. After retrieving a work item, the switch
statement determines what to do with it, based on the ActionType
. In this case, there is only one valid ActionType
, which is Add
.
string sql = "[Traffic].[AddPageView]";
using (SqlConnection conn = new SqlConnection(ConnString))
{
foreach (RequestInfo info in workItem.RequestInfoList)
{
using (SqlCommand cmd = new SqlCommand(sql))
{
cmd.CommandType = CommandType.StoredProcedure;
SqlParameterCollection p = cmd.Parameters;
p.Add("pageurl", SqlDbType.VarChar, 256).Value
= (object)info.Page ?? DBNull.Value;
try
{
conn.Open();
cmd.ExecuteNonQuery();
}
catch (SqlException e)
{
EventLog.WriteEntry("Application",
"Error in WritePageView: " +
e.Message + "
",
EventLogEntryType.Error, 104);
}
}
}
}
break;
}
}
}
catch (ThreadAbortException)
{
return;
}
catch (Exception e)
{
EventLog.WriteEntry("Application",
"Error in MarketModule worker thread: " + e.Message,
EventLogEntryType.Error, 105);
throw;
}
}
}
The remainder of the method uses ADO.NET to call a stored procedure synchronously to store the URL of the page. The stored procedure has a single argument, and you call it once for each RequestInfo
object that was stored with the WorkItem
. I will cover several techniques for optimizing this code later in the book.
The ThreadAbortException
is caught and handled as a special case, since it indicates that the thread should exit. The code also catches and logs generic Exception
s. Even though it’s not a good practice in most places, Exception
s that are thrown from a detached thread like this would be difficult to trace otherwise.
Using the worker thread is easy. First, start the thread:
WorkItem.Init(Work);
You can do that from the Init()
method of an HttpModule
, or perhaps from Application_Start()
in Global.asax
.
After that, just create a RequestInfo
object and pass it to QueuePageView()
along with the batch size:
WorkItem.QueuePageView(new RequestInfo(), 10);
You can also use background threads as a way of executing certain types of tasks one at a time, as an alternative to locking for objects that experience heavy contention. The advantage over locking is that the ASP.NET worker thread doesn’t have to block for the full duration of the task; you could write the request to a queue in a BeginAsyncHandler
method, and the thread would continue rather than block. Later, when the task completes, the background thread could signal an associated custom IAsyncResult
, which would cause the EndAsyncHandler
method to execute.
However, because of the significant additional overhead, this makes sense only when threads are frequently blocking for relatively long periods.
If your code accesses different areas of disk at the same time, the disk heads will have to seek from one area to another. Those seeks can cause throughput to drop by a factor of 20 to 50 or more, even if the files are contiguous. That’s an example of where you might consider using task serialization with a background thread. By accessing the disk from only one thread, you can limit seeks by not forcing the operating system to interleave requests for data from one part of the disk with requests for data from another part.
Whenever you have multiple threads, you should use locks to prevent race conditions and related problems. Locking can be a complex topic, and there’s a lot of great material that’s been written about it, so I won’t go into too much detail here. However, for developers who are new to asynchronous programming, I’ve found that it’s often helpful to establish a couple of basic guidelines:
- Use a lock to protect access to all writable data that multiple threads can access at the same time. Access to static data, in particular, should usually be covered with a lock.
- Avoid using a lock within another lock. If absolutely required, ensure that the order of the locks is always consistent to avoid deadlocks.
- Lock the minimum amount of code necessary (keep locks short).
- When deciding what code to lock, keep in mind that interrupts can happen between any two nonatomic operations and that the value of shared variables can change during those interrupts.
The standard C# lock
statement serializes access to the code that it surrounds. In other words, the runtime allows only one thread at a time to execute the code; all other threads are blocked. For cases where you mostly read and only infrequently write the static data, there is a useful optimization you can make. The .NET Framework provides a class called ReaderWriterLockSlim
that allows many readers, but only one writer, to access the locked code at the same time. The standard lock
doesn’t differentiate between readers and writers, so all accesses of any type are serialized.
For example, here are two shared variables, whose values need to be read or written at the same time in order for them to be consistent:
public static double Balance;
public static double LastAmount;
Here’s the declaration of the lock:
public static ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
Here’s the code to read the shared data:
rwLock.EnterReadLock();
double previousBalance = Balance + LastAmount;
rwLock.ExitReadLock();
If there is any chance of the locked code throwing an exception or otherwise altering the flow of control, you should wrap it in a try
/finally
block to ensure that ExitReadLock()
is always called.
Here’s the code to write the shared data:
rwLock.EnterWriteLock();
LastAmount = currentAmount;
Balance -= LastAmount;
rwLock.ExitWriteLock();
When you use the default constructor, the resulting object doesn’t support recursive (nested) locks. To allow recursive locks:
public static ReaderWriterLockSlim rwLockRecurse =
new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
If you enter this type of f lock again after you’ve already acquired it once, the runtime will increment an internal counter. Exiting the lock will decrement the counter until it reaches zero, when the lock will actually be released.
Web applications often have a requirement for managing information that is carried over from one HTTP request to another. For example, this information could include a logged-on user’s name, their role, authorization details, shopping cart contents, and so on.
In a load-balanced environment, each HTTP request from a given client might be routed to a different web server, so storing that state information on the web tier won’t work. The HTTP protocol itself is stateless; each connection carries no history with it about anything that has happened before other than what the browser presents in each request.
Session state is “historical” or state information that is useful only for the duration of a session. A session is the period that a client is “active,” which might be the time that they are logged on or perhaps the time that the browser is open.
The management of session state, or more often its mismanagement, is a significant issue that sites often encounter as they grow. Because it’s easy to use and yet presents a significant load to your back-end data store, it can become a significant barrier to scalability. From a scalability perspective, the best solution to avoiding session state problems is not to use it; most sites can get along fine with just cookies. Having said that, there are times when it’s useful and desirable.
ASP.NET includes a comprehensive set of mechanisms for managing session state. While the built-in system can work great for small to medium sites, it’s not sufficiently scalable as-is for large sites, although the system does have several hooks that will allow you to improve its scalability substantially.
Here’s an example of how to set session state from a web page:
this.Session["info"] = "this is my info";
You can then read the information in a subsequent request for the same page or a different one:
string myinfo = (string)this.Session["info"];
if (myinfo != null)
{
// myinfo will be set to "this is my info"
}
The Session
object is a specialized dictionary that associates a key with a value. The semantics are similar to those of the ViewState
object, as described in Chapter 3.
Session state works in part by associating some client-specific data called the session ID with a record that’s stored somewhere on the server side. The usual approach is to provide a unique session ID to each client as a cookie.
An alternative approach is to use cookieless session IDs, where the session ID is encoded in the URL. In many applications, providing ready access to session IDs, such as is possible when they are encoded in a URL, is a potential security risk. As I mentioned in Chapter 1, modern public-facing web sites will encounter very few real clients (as opposed to spiders) that don’t support cookies. For those reasons, I recommend using only the cookie-based approach.
The default implementation of session ID cookies by ASP.NET doesn’t assign an explicit expiration time to them. That causes the browser to consider them temporary, so it can delete the cookies on its own only when the browser’s window is closed. Temporary cookies never timeout; they are active as long as the window is open. When you provide an expiration time, the browser writes the cookies to disk, and they become (semi) permanent. The browser deletes them after they expire.
Both types of cookies have a role in implementing session state or alternatives, depending on the requirements for your site. You might want users to stay logged in for a while, even if they close the browser. You might also want the user’s session to timeout if they walk away from their computer for a while without closing the browser. In most cases, I prefer cookies to be permanent, with specific expiration times. If your application requires the session to end when a user closes the browser, then you might consider a custom provider with both a temporary cookie and a permanent one. Together, you will have all the information you need to take the correct action on the server. From a code complexity perspective, I prefer that approach to using temporary cookies with timeout information encoded into them.
The default configuration is to store session state information in the memory of the IIS worker process using InProc
mode. The advantage of this approach is that it’s very fast, since session objects are just stored in (hidden) slots in the in-memory Cache
object. The stored objects aren’t serialized and don’t have to be marked as serializable.
Since one worker process doesn’t have access to the memory in another, the default configuration won’t work for a load-balanced site, including web gardens. Another issue is that if the web server crashes or reboots or if the IIS worker process recycles, all current state information will be lost. For those reasons, I don’t recommend using InProc
mode, even for small sites.
One approach that some web sites take to address the problems with InProc
mode is to configure their load balancer to use sticky connections to the web servers. That way, the load balancer will assign all connections from a particular client to a particular web server, often based on something like a hash code of the client’s IP address. Although that solution partly addresses the scalability issue, the data is still stored in RAM only and will therefore still be lost in the event of a server failure or a worker process recycle.
In addition, using sticky connections introduces a host of additional problems. Since the load balancer is no longer free to assign incoming connections in an optimized way (such as to the server with the least number of active connections), some servers can experience significant and unpredictable load spikes, resulting in an inconsistent user experience. Those load spikes might result not just in purchasing more hardware than you would otherwise need, but they can also interfere with your ability to do accurate capacity planning and load trend forecasting.
Another option for storing session state is to use StateServer
, which is included as a standard component of ASP.NET. Unlike InProc
mode, StateServer
serializes objects before storing them.
StateServer
has the advantage of running outside of IIS, and potentially on a machine of its own, so your site will function correctly without sticky connections when it’s load balanced or a web garden.
However, as with the InProc
mode, StateServer
stores state information only in memory, so if you stop the process or if the machine reboots, all session data is lost. With StateServer
, you are effectively introducing a single point of failure. For those reasons, I don’t recommend using StateServer
.
Storing session state in a database addresses the reliability issues for both InProc
and StateServer
. If the database crashes or reboots, session state is preserved.
To enable use of the built-in SQL Server session provider, execute the following command from C:WindowsMicrosoft.NETFramework64v4.0.30319
:
aspnet_regsql -E -S localhost -ssadd -sstype p
The -E
flag says to use a trusted connection (Windows authentication). The -S
flag specifies which database server instance to use; for SQL Server Express, you should specify .SQLEXPRESS
, as you would with a connection string. -ssadd
says to add support for SQL Server session state. -sstype p
says to store both session state and the associated stored procedures in the newly created ASPState
database.
If you have trouble getting aspnet_regsql
to work correctly in your environment, the /?
flag will display a list of options.
If you’re curious, you can look in the InstallPersistSqlState.sql
file in that same folder for an idea of what the previous command will do. However, you shouldn’t execute that script directly since it’s parameterized; use aspnet_regsql
as shown earlier.
After you run aspnet_regsql
, if you take a look at SQL Server using SSMS, you’ll notice a new database called ASPState
, which has two tables and a bunch of stored procedures. You might need to configure the database to allow access from the identity that your web site’s AppPool uses, depending on the details of your security setup.
A SQL Agent job is also created, which runs once a minute to delete old sessions. You should enable SQL Agent so that the job can run.
Caution If you don’t enable SQL Agent so that it can run the job that periodically deletes expired sessions, you will find that sessions never expire. The standard session provider never checks the session’s expiration time.
Enable SQL Server session state storage by making the following change to web.config
:
<system.web>
<sessionState mode="SQLServer"
sqlConnectionString="Data Source=.;Integrated Security=True"
timeout="20"
cookieName="SS" />
. . .
</system.web>
The timeout
property specifies how long a session can be idle before it expires, in minutes.
The sqlConnectionString
property specifies the server to use. The database name of ASPState
is implied; the runtime won’t allow you to specify it explicitly unless you also set the allowCustomSqlDatabase
property to true
. As an alternative to including a full connection string, you can also use the name of one from the connectionStrings
section of your web.config
.
Using the cookieName
property, I’ve specified a short two-character name for the name of the session ID cookie instead of the default, which is ASP.NET_SessionId
.
If you’re interested in exploring how sessions work in more details, after running a small test page, along the lines of the earlier Session
example, you can query the tables in the ASPState
database to see that they are in fact being used. You can also take a look at the HTTP headers using Fiddler to see how the session ID cookie is handled and view the session-related database queries with SQL Profiler.
In some cases, you can improve the performance of your session state by compressing the serialized session dictionary before sending it to SQL Server. You enable automatic compression with GZipStream
by setting the compressionEnabled
property of the sessionState
element in web.config
.
Extending the earlier example:
<system.web>
<sessionState mode="SQLServer"
sqlConnectionString="Data Source=.;Integrated Security=True"
timeout="20"
cookieName="SS"
compressionEnabled="true" />
. . .
</system.web>
This can improve performance by reducing the load on the network and the database, at the expense of additional CPU time on the web server.
In spite of its positive aspects, database storage of session state does have some drawbacks. The biggest is that the standard implementation doesn’t adequately address scalability.
Having many web servers that talk to a single session database can easily introduce a bottleneck. One database round-trip is required at the beginning of a web request to read the session state, obtain an exclusive lock, and update the session’s expiration time, and a second round-trip is required at the end of the request to update the database with the modified state and release the lock. The runtime also needs to deserialize and reserialize the state information, which introduces even more overhead.
One side effect of the exclusive locks is that when multiple requests arrive from the same user at once, the runtime will only be able to execute them one at a time.
Writes to the database are particularly expensive from a scalability perspective. One thing you can do to help minimize scalability issues is to heavily optimize the database or file group where the session state is stored for write performance, as described in later chapters.
Something that can have even more impact is to limit which pages use session state and to indicate whether it’s only read and not written. You can disable session state for a particular page by setting the EnableSessionState
property to false
in the Page
directive:
<%@ Page EnableSessionState="false" . . . @>
If you try to access the Session
object from a page that has session state disabled, the runtime will throw an exception.
With session state disabled, even if you don’t access the Session
object, if the client has a session ID cookie set, the session provider still accesses the database in order to update the session timeout. This helps keep the session alive, but it also presents additional load on the database.
You can use the same property to indicate that the session data used by the page is read-only:
<%@ Page EnableSessionState="ReadOnly" . . . @>
The provider still updates the database with a new session expiration time, even in ReadOnly
mode, but it’s done by the same stored procedure that reads the session data, so it doesn’t require a second round–trip.
In addition to eliminating a second round–trip, setting read-only mode helps performance by causing the session provider to use a read lock on the database record, rather than an exclusive lock. The read lock allows other read-only pages from the same client to access the session data at the same time. That can help improve parallelism and is particularly important when a single client can issue many requests for dynamic content at the same time, such as with some Ajax-oriented applications, with sites that use frames, or where users are likely to issue requests from more than one browser tab at a time.
You can set the default for the EnableSessionState
property in web.config
:
<configuration>
<system.web>
<pages enableSessionState="false">
. . .
</pages>
. . .
</system.web>
</configuration>
In most environments, I suggest setting the default to false
and then explicitly enabling session state on the pages that need it, or setting it to ReadOnly
on pages that only need read access. That way, you avoid accidentally enabling it on pages that don’t need it.
It’s also a good idea to split functions that need session data only in read-only form onto separate pages from those that need read/write access to minimize further the write load on the database.
As I mentioned earlier, the standard support for session state using SQL Server unfortunately isn’t scalable for large sites. However, if it’s an important part of your architecture, the framework does provide a couple of hooks that make it possible to modify several key aspects of the implementation, which you can use to make it scalable.
Although the cost of serializing session state data can be significant, it normally has an impact mostly on the performance side, rather than on scalability. Since it’s a CPU-intensive activity, if your site is scalable, you should be able to add more servers to offset the serialization cost, if you need to do so. The time it takes to write the session data to the database is where scalability becomes an issue.
It is possible to use distributed caching technology, such as Microsoft’s Velocity, as a session state store. See the “Distributed Caching” section in Chapter 3 for a discussion of that option.
If the data you need is already in RAM, SQL Server can act like a large cache, so that read queries execute very quickly, with no access to disk. However, all INSERT
, UPDATE
, and DELETE
operations must wait for the database to write the changes to disk. I’ll cover database performance in more detail in later chapters. For now, the main point is that database scalability is often driven more by writes than reads.
To increase database write performance, the first step is to maximize the performance of your database hardware. Database write performance is largely driven by the speed with which SQL Server can write to the database log. Here are a few high-impact things you can do:
- Place the session database log file on its own disks, separate from your data.
- Use RAID-10 and avoid RAID-5 for the log disks.
- Add spindles to increase log write performance.
I’ll discuss those optimizations in more detail in later chapters.
Once you reach the limit of an individual server, the next step is to scale out. Your goal should be to distribute session state storage onto several different servers in such a way that you can figure out which server has the state for a particular request without requiring yet another round-trip. See Figure 5-4.
The default session ID is a reasonably random 24-character string. A simple approach you might use for partitioning is to convert part of that string to integer, take its modulo, and use that to determine which database server to use. If you had three servers, you would take the ID modulo three.
What complicates the issue for large sites is the possibility that you might want to change the number of session state servers at some point. The design shouldn’t force any existing sessions to be lost when you make such a change. Unfortunately, algorithms such as a simple modulo function that are based entirely on a set of random inputs aren’t ideal in that sense, since without accessing the database you don’t have any history to tell you what the server assignment used to be before a new server was added.
A better approach is to encode the identity of the session server directly into the session ID, using a custom session ID generator. Here’s an example (see App_CodeScalableSessionIDManager.cs
):
using System;
using System.Web;
using System.Web.SessionState;
namespace Samples
{
public class ScalableSessionIDManager : SessionIDManager
{
Here you are going to extend the default SessionIDManager
class. You only need to override two methods to implement custom session IDs. If you also wanted to modify the way the session cookies are handled, you would implement the ISessionIDManager
interface instead.
public static string[] Machines = { "A", "B", "C" };
private static Object randomLock = new Object();
private static Random random = new Random();
public override string CreateSessionID(HttpContext context)
{
int index;
lock (randomLock)
{
index = random.Next(Machines.Length);
}
string id = Machines[index] + "." + base.CreateSessionID(context);
return id;
}
Pick a random number between zero and the length of the Machines
array. This index determines which database server you’ll use to store the session state.
If the hardware you’re using for each of your session servers is not identical, you could apply weighting to the random assignments to allow for the difference in performance from one server to another.
Since creating the Random
class involves some overhead, use a single instance of it to generate the random numbers. Since its instance methods are not thread safe, get a lock first before calling Random.Next()
. In keeping with best practices for locking, create a separate object for that purpose.
Finally, you create the session ID by concatenating the machine ID with a separator character and the ID provided by the base class. This approach will allow you to add new session servers later if needed, without disturbing the existing ones, since the session server assignment is encoded in the session ID.
public static string[] GetMachine(string id)
{
if (String.IsNullOrEmpty(id))
return null;
string[] values = id.Split('.'),
if (values.Length != 2)
return null;
for (int i = 0; i < Machines.Length; i++)
{
if (Machines[i] == values[0])
return values;
}
return null;
}
public override bool Validate(string id)
{
string[] values = GetMachine(id);
return (values != null) && base.Validate(values[1]);
}
The static GetMachine()
method parses your new session IDs and makes sure that they contain a valid session server ID. The overridden Validate()
method first calls GetMachine()
to parse the session ID and then passes the part of it that originally came from the base class to the Validate()
method in the base class.
To map your new session IDs into appropriate database connection strings, you use a partition resolver. See App_CodeScalablePartitions.cs
:
using System.Web;
namespace Samples
{
public class ScalablePartitions : IPartitionResolver
{
Implement the IPartitionResolver
interface, which contains only two methods: Initialize()
and ResolvePartition()
.
private string[] sessionServers = {
"Data Source=ServerA;Initial Catalog=session;Integrated Security=True",
"Data Source=ServerB;Initial Catalog=session;Integrated Security=True",
"Data Source=ServerC;Initial Catalog=session;Integrated Security=True"
};
Specify the connection strings for the different servers. During testing, you might configure this either with different database instances or with different databases in a single instance.
public void Initialize()
{
}
public string ResolvePartition(object key)
{
string id = (string)key;
string[] values = ScalableSessionIDManager.GetMachine(id);
string cs = null;
if (values != null)
{
for (int i = 0; i < ScalableSessionIDManager.Machines.Length; i++)
{
if (values[0] == ScalableSessionIDManager.Machines[i])
{
cs = sessionServers[i];
break;
}
}
}
return cs;
}
Initialize()
is called once per instance of the class. This implementation doesn’t require any instance-specific initialization, so that method is empty.
ResolvePartition()
receives the session ID as its argument. Pass the ID to the static GetMachine()
shown earlier, which will parse the ID and return a two-element array if it’s properly formatted. The first element in the array is the key that determines which session server to use. After finding that key in ScalableSessionIDManager.Machines
, use its index to determine which connection string to return.
To tell the runtime to use the new code, make the following change to web.config
:
<system.web>
<sessionState sessionIDManagerType="Samples.ScalableSessionIDManager"
partitionResolverType="Samples.ScalablePartitions"
mode="SQLServer" timeout="20" cookieName="SS"
allowCustomSqlDatabase="true" />
. . .
</system.web>
The sessionIDManagerType
property specifies the class name for the custom session ID manager. The partitionResolverType
property specifies the class name for the partition resolver. Setting mode
to SQLServer
causes the SQL Server session provider to be used. The cookieName
property gives a nice short name for the session state cookie.
Setting allowCustomSqlDatabase
to true
allows you to include the name of a database in the connection strings returned by the partition resolver. That’s particularly useful during development, when you might want to use several different databases on the same server. The default setting of false
prevents that, which forces use of the default ASPState
database.
The database connection string that you may have previously included in the <sessionState>
section is no longer needed, since the partition resolver will now provide them.
To test the code, create a new web form called session1.aspx
. Enable session state in the Page
directive:
<%@ Page EnableSessionState="True" Language="C#" AutoEventWireup="true"
CodeFile="session1.aspx.cs" Inherits="session1" %>
Next, replace the code-behind with the following:
using System;
using System.Web.UI;
public partial class session1 : Page
{
protected void Page_Load(object sender, EventArgs e)
{
this.Session["test"] = "my data";
}
}
Unless you store something in the Session
object, the runtime won’t set the session cookie.
Start the Fiddler web debugger and load the page. The response should include a Set-Cookie
header, something like the following:
Set-Cookie: SS=C.ssmg3x3t1myudf3osq3whdf4; path=/; HttpOnly
Notice the use of the cookie name that you configured, along with the session server key at the beginning of the session ID.
ASP.NET also displays the session ID on the page when you enable tracing:
<%@ Page Trace="True" . . . %>
You can verify the use of the correct database by issuing an appropriate query from SSMS. For example:
SELECT *
FROM ASPStateTempSessions
WHERE SessionID LIKE 'C.ssmg3x3t1myudf3osq3whdf4%'
You need the LIKE
clause since the session provider creates the database key by appending an application ID to the value in the session ID cookie. The provider generates the application ID by computing a hash of the application name, which you can get from HostingEnvironment.ApplicationID
. That allows a single ASPState
database to support more than one application. See the TempGetAppID
stored procedure for details.
You should address several additional issues in your support for performance-optimized session state. Notice that the standard session provider doesn’t set an expiration date on the session ID cookie, which results in a browser session cookie. That means if the user closes the browser’s window, the cookie may be dropped. If the user never closes the browser, the cookie will never be dropped. Notice too that the path
is set to /
, so the cookie will be included with all requests for the given domain. That introduces undesirable overhead, as I discussed in Chapters 2 and 3. Unfortunately, the default implementation doesn’t provide a way to override the path
.
The default session IDs aren’t self-validating. The server needs to issue queries to the database to make sure that the session hasn’t expired and to update its expiration date. Also, as I mentioned earlier, even when sessions have been disabled on a page, once a user activates a session, a database round-trip is still made in order to update the session’s expiration time. In keeping with the core principles as outlined in Chapter 1, it would be nice to eliminate those round-trips.
One approach would be to encode the session expiration time in the session cookie (or perhaps a second cookie), along with a hash code that you could use to validate the session ID and the expiration time together. You could implement that as a custom ISessionIDManager
.
To get full control over the way session state is managed, you will need to replace the default session HttpModule
. Such a solution would involve implementing handlers for the AcquireRequestState
and ReleaseRequestState
events to first retrieve the session data from the database and then to store it back at the end of the request. You will need to handle a number of corner cases, and there are some good opportunities for performance optimization. Here is a partial list of the actions your custom session HttpModule
might perform:
- Recognize pages or other
HttpHandler
s that have indicated they don’t need access to session state or that only need read-only access- Implement your preferred semantics for updating the session expiration time
- Call your
ISessionIDManager
code to create session IDs and to set and retrieve the session ID cookie- Call your
IPartitionResolver
code to determine which database connection string to use- Serialize and deserialize the
Session
object- Implement asynchronous database queries and async
HttpModule
events- Implement your desired locking semantics (optimistic writes vs. locks, and so on)
- Handle creating new sessions, deleting old or abandoned sessions, and updating existing sessions
- Ensure that your code will work in a load-balanced configuration (no local state)
- Store and retrieve the
Session
object to and fromHttpContext
, and raiseSessionStart
andSessionEnd
events (perhaps using theSessionStateUtility
class)
There are also details on the database side, such as whether to use the default schema and stored procedures or ones that you’ve optimized for your application.
You might also consider transparently storing all or part of the Session
object in cookies, rather than in the database. That might eliminate the database round-trips in some cases.
The standard session state provider uses a serialization mechanism that efficiently handles basic .NET types, such as integers, bytes, chars, doubles, and so on, as well as DateTime
and TimeSpan
. Other types are serialized with BinaryFormatter
, which, unfortunately, can be slow. You can reduce the time it takes to serialize your session state by using the basic types as much as possible, rather than creating new serializable container classes.
If you do use serializable classes, you should consider implementing the ISerializable
interface and including code that efficiently serializes and deserializes your objects. Alternatively, you can mark your classes with the [Serializable]
attribute and then mark instance variables that shouldn’t be serialized with the [NonSerialized]
attribute.
Something to be particularly cautious about when you’re using custom objects is to avoid accidentally including more objects than you really need. BinaryFormatter
will serialize an entire object tree. If the object you want to include in session state references an object that references a bunch of other objects, they will all be serialized.
Tip It’s a good idea to take a look at the records that you’re writing to the session table to make sure that their sizes seem reasonable.
With a custom session HttpModule
, you might also want to check the size of the serialized session and increment a performance counter or write a warning message to the log if it exceeds a certain threshold.
For cases where you only need the data on the client, Silverlight and web storage can provide good alternatives to session state. That way you can use the data locally on the client, without requiring the browser to send it back to the server. If the server does need it, you can send it under program control, rather than with every request as would happen with cookies. Instead of using the Session
-based API, your web application would simply pass state information to your Silverlight app or JavaScript as part of the way it communicates for other tasks, such as with web services.
Cookies are another alternative. As with Silverlight and web storage, the easy solution here involves using cookies directly and avoiding the Session
-based API.
However, if your site already makes heavy use of the Session
object, it is also possible to write a custom session provider that would save some state information to cookies. You could save data that is too big for cookies or that might not be safe to send to clients even in encrypted form in a database. For sites that need session state with the highest possible performance, that’s the solution I recommend.
Cookies have the disadvantage of being limited to relatively short strings and of potentially being included with many HTTP requests where the server doesn’t need the data. They are also somewhat exposed in the sense that they can be easily sniffed on the network unless you take precautions. In general, you should therefore encrypt potentially sensitive data (such as personal information) before storing it in a cookie. In Chapter 2, I’ve provided an example for encrypting and encoding data for use in a cookie.
When using cookies as an alternative to session state, you should set their expiration times in a sliding window so that as long as a user stays active, the session stays alive. For example, with a 20-minute sliding expiration time, when the user accesses the site with 10 minutes or less to go before the cookies expire, then the server should send the session-related cookies to the client again with a new 20-minute expiration time. If users wait more than 20 minutes between requests, then the session times out and the cookies expire.
The other guidelines that I described for cookies in Chapter 2 also apply here, including things such as managing cookie size and using the httpOnly
, path
, and domain
properties.
In this chapter, I covered the following:
- How synchronous I/O can present a significant barrier to system scalability
- How you should use asynchronous I/O on web pages whenever possible for database accesses, filesystem I/O, and network I/O such as web requests and web services
- Using background worker threads to offload the processing of long-running tasks
- Why you should avoid using session state if you can, and why cookies, web storage, or Silverlight isolated storage are preferable
- In the event your application requires session state, how you can improve its scalability by strategically limiting the way it’s used, and by using custom session IDs, a partition resolver, and a custom session management
HttpModule
3.15.12.34