You are on page 1of 9

C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 1 of 9

-->
Programmer to Programmer

Search C#Today
Living Book

i Index n
j
k
l
m
n j Full Text
k
l
m
Advanced

CATEGORIES HOME SITE MAP SEARCH REFERENCE FORUM FEEDBACK ADVERTISE SUBSCRIBE

The C#Today Article Previous article -


May 20, 2002 May 13, 2002

Multi-threading In C#
by Ramon Arjona

CATEGORIES: Application Development, C# Language, Other Technologies


ARTICLE TYPE: Overview Reader Comments

ABSTRACT Article Rating


C# and the .NET platform offer developers unprecedented support for threaded applications targeted to Useful
run on Windows. This opens up a variety of opportunities and problems for developers who are
interested in exploiting the power of threads. In this case study, Ramon Arjona addresses several of Innovative
these opportunities, and outlines solutions to many of these problems by examining an implementation
of a Quote of The Day (QOTD) server. We discuss the appropriate use of threads in an application, and Informative
touch on the important differences between threading in C#, Java, and traditional Microsoft Foundation
Class (MFC) development. 1 response

Article Discussion Rate this article Related Links Index Entries

ARTICLE

Editor's note: This is a sample version of the case study. The full version, which includes the code
download for this piece, is only available to subscribers. If you wish to subscribe, please click on the
subscribe link in the menu bar at the top of the screen. If you have purchased a subscription to
C#Today, click here to view the full version of the article.

C# and the .NET platform offer developers unprecedented support for threaded applications targeted to run
on Windows. This opens up a variety of opportunities and problems for developers who are interested in
exploiting the power of threads. This case study addresses several of these opportunities, and outlines
solutions to many of these problems by examining an implementation of a Quote of The Day (QOTD) server,
which should be familiar to us from its many popular incarnations targeted to the UNIX and Linux platforms.
We discuss the appropriate use of threads in an application, and touch on the important differences between
threading in C#, Java, and traditional Microsoft Foundation Class (MFC) development.

Introduction

Everybody needs to multi-task in today's fast paced technical environment. For instance, while I am writing
this case study, I am also microwaving a gourmet burrito dinner, listening to blues music, and checking the
clock to see if I'll actually make my deadline. My computer, likewise, is doing several things at once. It is
accepting my keyboard input, playing my blues CD, and monitoring my Internet connection. If I - or my
computer - could only manage one task at a time, tasks would pile up and life would rapidly grind to a halt
while I waited for each individual task to complete.

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 2 of 9

The need to do multiple things at once is what makes multi-threaded programming techniques necessary. You
will often hear threads referred to as lightweight processes (LWPs), but I find that it's useful to think of a
thread as a "unit of work." For instance, the task "microwave dinner" runs on one thread, while the task
"listen to blues" runs on another. At the moment, I'm doing about as many things at once as I can - I've got
tasks running on every available thread. If my wife comes home and asks me to do something, I'll need to
interrupt one of these threads (i.e., put my dinner down) or she will have to wait until one of the threads I'm
running dies or goes to sleep (i.e. my CD stops playing).

Most modern desktop operating systems support multi-threading. Even Mac OS X, with its POSIX-based
kernel, has native thread support. Developers targeting operating systems that do not support threads
natively could still leverage the power of multi-threading - after a fashion - if there is a Java Virtual Machine
(JVM) for that OS, because the JVM can implement a kind of logical threading model that is divorced from the
underlying OS and hardware, called "green threads". (You don't need to know anything about green threads
for this case study, and I sincerely hope you never need to know anything about them, ever. My point is that
multi-threaded programming is possible, though perhaps ill-advised, even on operating systems that don't
have native thread support.) On the Windows platform, support for threads has been available since the days
of Windows 95 and Windows NT.

So why haven't all Windows developers been busily developing multi-threaded applications over the past six
years? Some of us have, but many of us have been content to continue to program in a "synchronous" model,
waiting for one task to complete before moving on to the next one.

Partly this is because of the cryptic implementation of threading in COM and MFC. In COM, in order to
program a multi-threaded object, you would sometimes have to do something called "aggregating the free-
threaded marshaller." You would also have to understand the differences between the various apartment
models in COM threading: single threaded apartments, multi-threaded apartments, and neutral apartments.

Doesn't that sound complicated? Even for seasoned coders, it could get frustrating. And don't forget that in
order to implement a multi-threaded program on Windows, you had to code it using C++. Visual Basic
developers, until the release of Visual Basic.NET, had no easy way to implement a multi-threaded program.
Of course, you could always have resorted to implementing your multi-threaded application in the Java
language, and some of us did - only to find that integrating Java with COM could be a pain and that Java
threads have, well, some room for improvement. (There is an excellent article detailing many of the problems
with Java threads and what could be done to fix them. I won't repeat any of it here, but if you're interested
the link can be found at the end of this case study.)

Now, with the advent of .NET and the magic of the Foundation Class Library, all of this has changed. Windows
developers now have a straightforward, easy to understand idiom for implementing threaded applications,
and we don't even have to tie ourselves to a particular language to use it - any language that targets the
Common Language Runtime (CLR) can leverage .NET threads (even poor old VB).

The problem I've been faced with is a relatively straightforward one: build a server that implements the same
behavior as the QOTD daemon on UNIX, which powers the familiar fortune game on UNIX and Linux. The
QOTD service needs to maintain a list of pithy quotes, and spit them out randomly over a TCP channel to
anyone who connects to the appropriate port. While QOTD is entertaining, it is definitely not "just for fun".
From the work that goes into QOTD comes the infrastructure necessary to implement chat daemons, mail
daemons, and all other network services that must support large numbers of concurrent users. In fact, I
started working on QOTD as a sidetrack while building an irc daemon from scratch in C#.

What Are Threads Good For?

Certain kinds of problems obviously lend themselves to multi-threaded solutions. For instance, if we have an
application that will run on a multi-processor box, we can use threads to take advantage of parallel
processing. While one of our application threads is executing on Processor 1, another thread can be executing
on Processor 2. This allows us to make efficient use of the entire machine and can buy us significant
performance gains.

Threads are also useful when the responsiveness of an application is an issue. For instance, if we have a long
task that we want to execute, we likely don't want our application to sit idly by while it waits for the long task
to complete. Instead, we can spawn a new thread on which the long task can run, and allow it do its work
while our application gets on with other tasks. Meanwhile, this long running task is processed asynchronously,
and lets us know when it's done via a callback method.

Threading can also be useful when we want to share resources between users. Web servers, for instance,

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 3 of 9

wouldn't be too useful if only one user could request a given HTML page at a time.

However, when making the decision to use threads or not, it is important to weigh any potential benefits
against the cost of implementing and debugging a multithreaded solution. Multi-threading raises a host of
issues - like concurrency management, contention issues, and deadlocks. Synchronous solutions can simply
pretend that these issues don't exist.

Many other writers have said it, but I'll say it again: multi-threading is not a magic bullet. I'm reminded of a
recent post to the comp.programming.threads newsgroup, in which the poster was asking for help refactoring
threads out of his application. Weigh your options carefully, and choose your solution set with care.

System Requirements

The code in this case study requires Windows 2000 Server and the released version of the .NET Framework
SDK. You do not need to have a network connection, but you do need to have a network card or modem and
TCP/IP installed correctly for this to work. You should have a good grasp of C# and of .NET Intermediate
Language (IL). C# does not require you to have a particularly low level knowledge of threads or threading,
and neither will this case study. If you're interested in the implementation of threads at the kernel level, look
at the links at the bottom of this case study.

Defining The Problem

The problem is a straightforward one: we need to build a service that will run on Windows 2000 and support a
large number of concurrent users who will connect to the service via TCP/IP. This presents us with several
problems: How do we manage concurrent read/write access to our data? How do we deal with organizing and
queuing multiple user requests? How can we debug the server, and how can we profile its performance once
we're done?

Case Study Structure

The case study is broken into three parts:

l The first part will examine the initial design and implementation of the application, using a command console
l The second part will describe the test driver for the application, and changes that were made once test
results were derived
l The third part will show how the application is installed as a service.

Database Design and Structure

This section is only available to subscribers.

Component Design and Coding

QuoteLibrary Object

The QuoteLibrary object is where our quotes actually reside. It derives from System.Data.DataSet,
because the ADO.NET dataset object already addresses much of the problem domain surrounding the storage
of our quotes, including the creation of referential integrity constraints, identity columns that autogenerate
new ids as we add rows to a table, and serialization.

An instance of the QuoteLibrary object is shared by the QuoteEngine object and the QuoteAdmin object. The
QuoteEngine object only needs to read from this resource, but the QuoteAdmin object needs to read from it
and write to it. The QuoteLibrary object itself doesn't know anything about the objects that are calling it - but
it does not trust that it's callers will guarantee that they are making their calls in a thread-safe fashion.
Therefore, the QuoteLibrary object takes it upon itself to ensure that its data is used in a thread safe fashion
by locking itself during every read and write operation.

At this point, we should take a moment to define what we mean by "thread-safe", and explain why it's a good
idea for the QuoteLibrary to manage its own thread-safety.

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 4 of 9

A thread, as we said above, is a "logical unit of work." Many of these "logical units of work" can be active in a
given application domain at the same time. Each thread maintains its own stack, which contains data that is
local to that particular thread. All of the threads in an application domain, however, share resources like
memory, file handles, and database connections. When we say that our application is "thread-safe", we mean
that the threads share these resources nicely - without, for instance, clobbering data out from under each
other.

Let's pretend that my wife and I are two threads in an application domain. Think of the application domain as
our "house." We have our own data and our own local stack (for instance, my "To Do" list and her "To Do"
list), but we share other resources (for instance, our kitchen and our checkbook). Sharing the resources in
our house works out nicely most of the time, as it would become prohibitively expensive to build two kitchens
- but we have to share nicely.

Essentially, the problem is that there is no way to guarantee when a particular thread will do something to a
shared resource. The scheduling of threads is largely up to the CLR, though we can influence the amount of
time the CLR grants to a running thread by increasing it's priority. Each thread is granted a slice of processor
time to do its work, after which the CLR allows other threads a chance to execute. For instance, Thread A
might read data from a file in preparation for some long analytical operation. The CLR then gives Thread B a
chance to execute, and Thread B writes some data to the end of the shared file. Thread A is then allowed to
resume executing and, because it knows nothing about the work Thread B just did, it over-writes the Thread
B's changes as it is writing back to the file.

We need, therefore, to provide a means for our threads to synchronize their actions when dealing with shared
resources. Otherwise, concurrency bugs will appear and chaos will ensue as threads trip over each other
while they do their work. When we deal with our shared resources in a way that avoids this chaos by using
proper synchronization, we can say that we are being "thread safe."

The .NET runtime provides a variety of synchronization mechanisms, some of which we will deal with in this
case study. The most easily implemented synchronization mechanism in C# is the lock keyword:

lock(this)
{
this.Tables["QuoteType"].Rows.Add(row);
this.AcceptChanges();
this.Write();
}

This keyword places a lock on an object. Only one thread can hold the lock at a time. If a thread tries to
acquire this lock while another thread already holds it, this first thread goes into a sleeping state while it waits
for the lock to be released. The lock lasts for the duration of the code in the curly braces following the lock
statement.

Now, we don't have to implement locking in the QuoteLibrary object itself. We could, instead, choose to trust
the callers of the QuoteLibrary object to implement locking properly - but this approach is naive and should be
avoided. The callers of QuoteLibrary should not have to know how the object manages its resources.

Details such as locking, and the implementation of locking, should be opaque to objects that use
QuoteLibrary. The objects that use QuoteLibrary, after all, should only care about the public methods and
properties that it exposes. They should not care about private implementation details, such as the way
QuoteLibrary chooses to manage its data. Allowing implementation details like this to "bleed" into calling
classes violates the principle of encapsulation and therefore undermines an object-oriented design
methodology. This in turn makes changes and optimizations to the QuoteLibrary object more difficult to
implement.

For instance, in the section below on profiling the application, we will see that the lock keyword is a
suboptimal solution for our problem. If we had relied on the calling objects to implement locking, we would
have had to make code changes in each of the callers to alter our method of synchronization. Since each
caller would essentially duplicate the synchronization code, we'd have to track down every place where this
code appears and make our change - an annoying and error prone process.

We're much better off allowing the objects that encapsulate our shared resources to manage their own
synchronization whenever possible.

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 5 of 9

An example of this is illustrated by the code snippet below, taken from the QuoteLibrary object, illustrating the
way this object manages its own synchronization by placing a lock around the this pointer:

private void Write()


{
lock(this)
{
this.WriteXml("quotelib.xml", XmlWriteMode.WriteSchema);
}
}

QuoteEngine object

This section is only available to subscribers.

QuoteAdmin object

The core of the QuoteAdmin object is essentially the same as the QuoteEngine. There is a DoStart method
that handles the initial start-up of our object, establishing a TcpListener on port 6061, and queuing work items
to the ThreadPool.

The DoWork method of QuoteAdmin, however, is more substantial because we need to implement a means
for users to input information. We therefore implement a simple protocol, with a small set of commands, and
we parse the user's input as it is entered. For now, we presume that the user is connected to our service via a
simple telnet session, so our input buffer is only 4 bytes wide - just wide enough to receive a single character,
which we then append to a StringBuilder object. Once we've received a newline character, we know that the
user has finished telling us what they wanted to tell us and we can process their command.

This method is prepared to deal with two kinds of exceptions - DataExceptions raised when a user tries to add
information that violates the referential integrity constraints we've established in our ADO.NET data set, and
Format Exceptions that occur when the user attempts to send us a malformed string. In both cases, the user
is simply apprised of the error and we move happily on.

The Test Driver

Once I finished the QOTD service itself, I needed a way to test it. I built a simple, console-based application
that attempts to connect to the QOTD service over and over. If you're going to run the test application
yourself, it is best to run it on a separate machine than is running the QOTD server. Otherwise the QOTD
service will have to contend with the test driver for processor time, which is not quite what we want for our
test case.

The test driver spawns a large number of individual threads. These threads attempt to connect to the QOTD
server, and then go to sleep for a random number of milliseconds between 200 and 1000 before trying to
connect again. While running this test application, I used the performance monitor and the counters for CLR
threads to monitor what was going on with the server.

One thing that might be of some interest in the test driver is the use of Interlocked.Increment. The
Interlocked object supports a set of static methods that provide for atomic operations on variables that
multiple threads share. In other words, an increment or decrement operation happens with a single operation,
and is guaranteed to be unaffected by context switching between threads. The familiar post-fix increment
operator (i.e. ++i), for instance, actually performs a fetch operation, then an increment operation, which
could lead to strange behavior if the variable being incremented is shared amongst several threads, all of
which are subject to context switching by the operating system.

I would not, however, recommend using the Interlocked object the way I do in this test driver. Based on the
high amount of contention I noticed while watching the test driver itself in the performance monitor, it's clear
that I am spawning more threads than is optimal, and dealing with my resources in a less-than-ideal way. It's
good enough for our purposes, as it is a simple test driver meant to give our service a workout, but I do not
want to leave you with the impression that this is a good way to implement production code that you intend to
distribute.

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 6 of 9

The Performance Metrics

This section is only available to subscribers.

A Word about Thread.Abort

I have run across some controversy and confusion surrounding the Thread.Abort method, so I would like to
spend some additional time talking about what it is and what it is not.

First, as I mentioned above, the Thread.Abort method signals that a Thread object is being aborted by
throwing an unchecked ThreadAbortException. Some newsgroup posters feel that throwing an exception this
way is "inelegant." The gist of their argument, as I understand it, is this: since the stopping of a Thread is an
expected case, rather than an error case, we shouldn't use an exception to report it. This, in my opinion,
stems from a commonly held misperception about exceptions and exception handling, and shouldn't influence
the way we code our multithreaded applications.

Simply put, an exception is not the same as an error. An exception might be used to report an error
condition, but it can also be used to report a condition that we expect, but that violates some basic
assumption that is intrinsic to the operation we're executing at the time.

If we're building a library that other developers will use to perform HTTP client operations, for instance, we
expect a variety of cases that violate our intrinsic assumptions. Web servers, for instance, will occasionally
return a header indicating that a resource has moved. This goes against what should be one of our intrinsic
assumptions - that when we request a resource over the Web, we'll get a meaningful stream of bits back
representing that resource. However, what does the application code need to do about it?

Well, as the library developers, we don't know what the application code needs to do about it, because we're
not necessarily the ones writing the application that calls into our library. Some applications may want to
follow the resource moved header to its destination, hopefully finding the resource we wanted in the first
place at the end of it. Other applications may want to give up in despair after the first try. We need to tell the
calling application that there's a condition that violates our assumptions, and then let that application do
whatever it needs to do about the condition based on whatever the application's purpose is - so we throw an
exception. (We could return a cryptic error code, like an HRESULT, but exceptions are much cleaner and
easier to debug.)

An in depth discussion of the theory and practice of exceptions is beyond the scope of this case study. My
only point is that there is nothing inherently ugly about using an unchecked exception to notify the system
that a Thread is being stopped. For a more detailed view of exceptions and exception handling in .NET and
C#, you might want to consult Jeffrey Richter's "Applied Microsoft .NET Framework Programming". There are
also a number of good books published by Wrox that cover this topic.

Second, I have frequently heard the Thread.Abort method compared to the deprecated stop() method that
appears in the Java language. The similarities between these two methods are completely superficial, and in
my opinion comparisons between them are not useful.

Thread.Abort and Java's stop() both throw unchecked exceptions to notify the system that a thread is being
stopped. (In Java, the type of this exception is ThreadDeath.) That's pretty much all they have in common.

Java's stop() is very difficult to use, and very brutal in its execution. For instance, if we call stop() on a Java
thread while executing a finally block, the rest of that finally block won't execute. If we call stop() on a
Java thread while that thread is doing something through the Java Native Interface (JNI), we have no
guarantee that it will actually exit from that JNI call before it ends. Simply put, Java's stop() is a key
ingredient in a recipe for disaster. That's why it's been deprecated.

Thread.Abort, according to the specification and according to my experience so far, suffers from none of
these problems. Thread.Abort politely lets finally blocks execute, and while this might result in a Thread
never actually dying if we, for instance, create an infinite loop in our finally block (which would be a Bad
Thing in any case, with or without multi-threading), it is certainly better than arbitrarily breaking out of a
finally block that we were counting on. (The point of finally, after all, is that it always runs, whether
there was an exception or not.) As we mentioned above, Thread.Abort also waits politely while we exit from
unmanaged code. As with the finally block, this could create a condition in which a Thread is never given
an chance to stop - but it is preferable to exiting three-quarters of the way through a JNI call, leaving memory
mangled and important resources unreleased.

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 7 of 9

My point here is: do not be afraid of Thread.Abort, even if you are a Java coder who has been traumatized by
stop(). They are not the same thing. Thread.Abort is much easier to use, and in my opinion much less likely
to result in strange errors or dire frustration than Java's stop().

That Old Black Magic

Since I've already digressed a little to talk about Thread.Abort, it won't hurt if I digress a little further and talk
about the .NET runtime's internal implementation of Threads. If you're anything like me, you're probably
curious about how threading is implemented in .NET, and how it relates to the threading library in the
underlying platform.

Well, if you decompile MSCorlib.dll using ILDASM and go looking for the interesting bits of the Threading
implementation, you'll see something like this:

.method public hidebysig instance void Abort() cil managed


{
// Code size 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void System.Threading.Thread::AbortInternal()
IL_0006: ret
} // end of method Thread::Abort

.method private hidebysig instance void


AbortInternal() cil managed internalcall
{
} // end of method Thread::AbortInternal

Doesn't look like there's a whole lot going on, does it?

The internalcall keyword is what's important here. It tells the compiler that this is a "manually managed"
method. Essentially, the method is built into the .NET runtime itself, an implementation detail that is hidden
from our immediate view, implemented in native code. I have yet to find any documentation about internalcall
and "manually managed" methods outside of unofficial admonitions to avoid them as difficult to implement
sources of bugs, that lead developers to wail and gnash their teeth. So, at least for the time being, we must
be content with trusting the system and knowing that a "miracle" occurs, and allows our threads to work.

Mutexes

This section is only available to subscribers.

Building The Service

Once I was satisfied with the QOTD implementation, I went on to set it up to run as a service on my Windows
2000 machine.

I created a class derived from System.ServiceProcess.ServiceBase, which consumes the QOTD objects in
essentially the same way that the command line application did. Then I created a ServiceInstaller for the
service, which provides the necessary hooks for the install utility to install the service.

The only appreciable difference in the service implementation is that I use the DoStop method of the
QuoteEngine object. This method calls the abort method of the currently running thread, causing a
ThreadAbortException to be thrown. This exception causes the thread to exit at the earliest possible point, but
allows the application to finish processing finally blocks and also waits until the application returns to managed
code if it has stepped into unmanaged code. As the documentation points out, it is therefore possible to create
a situation in which a thread will never exit because of something we're doing in a finally block. This makes it
very important to avoid infinite loops and long processes inside of finally blocks.

After calling the Abort method, we call the Join method, sending the thread into the WaitSleepJoin state while
we wait for the abort request to be processed. This ensures that the caller of the thread will block until the

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 8 of 9

thread terminates.

The Sample Application

This section is only available to subscribers.

Any Limitations or Further Work

There is still a decent amount of work that remains to be done in the QOTD application. For instance, the
service was designed with the basic assumption that there would be many readers, but very few writers. It
would probably be useful to refactor this design somewhat, allowing for a larger number of individuals to
contribute their wisdom to the QOTD database. It would also be useful to constrain the list of people who are
able to use the admin function, likely by integrating with Active Directory and providing a basic
login/password prompt for the Admin UI.

The code also presumes that human beings are going to be the only ones interested in adding or reading
quotes. It would be useful to rethink this somewhat, and change the code to allow for bots to have easier
access to both the reading and the writing functions of the service.

Conclusion

This section is only available to subscribers.

Case Study Information


Author Ramon Arjona
Technical Editor Adam Ryland
Project Manager Helen Cuthill
Reviewers Sean Schade, Susan Connery, Matthew MacDonald

If you have any questions or comments about this case study, or suggestions for further pieces,
please contact the technical editor. If you wish to talk about the case study with other
subscribers, please visit our forum here.

RATE THIS ARTICLE USEFUL LINKS


Related Tasks:
Please rate this article (1-5). Was this article...

Useful? No l Enter Technical Discussion on this Article


j n
k
l
m
n j n
k
l
m j n
k
l
m j Yes,
j n
k
l
m k
l
m l Technical Support on this article -
Very
support@csharptoday.com
Innovative? No
j n
k
l
m
n j n
k
l
m j n
k
l
m j Yes,
j n
k
l
m k
l
m l See other articles in the Application Development
Very
l See other articles in the C# Language category
Informative? No l See other articles in the Other Technologies category
j n
k
l
m
n j n
k
l
m j n
k
l
m j Yes,
j n
k
l
m k
l
m l See other Overview articles
Very
l Reader Comments on this article
l Go to Previous Article
Brief Reader
Comments?
Your Name:
(Optional)

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002
C#Today - Your Just-In-Time Resource for C# Code and Techniques Page 9 of 9

Search the C#Today Living Book

i Index
j
k
l
m
n j Full Text
k
l
m
n Advanced

HOME | SITE MAP | INDEX | SEARCH | REFERENCE | FEEDBACK | ADVERTISE

Ecommerce Performance Security Site Design XML SOAP/Data Transfer


Data Application
Web Services Graphics/Games Mobile C# Language
Access/ADO.NET Development
Other Technologies

C#Today is brought to you by Wrox Press (www.wrox.com). Please see our terms and conditions and privacy policy
C#Today is optimised for Microsoft Internet Explorer 5 browsers.
Please report any website problems to webmaster@csharptoday.com. Copyright © 2002 Wrox Press. All Rights Reserved.

http://.../20020520.asp?UseHB=True&WROXEMPTOKEN=623042ZrEMaTsnnCIxeDmD4OR 5/24/2002

You might also like