Windows via C/C++: Synchronous and Asynchronous Device I/O
- 11/28/2007
I can’t stress enough the importance of this chapter, which covers the Microsoft Windows technologies that enable you to design high-performance, scalable, responsive, and robust applications. A scalable application handles a large number of concurrent operations as efficiently as it handles a small number of concurrent operations. For a service application, typically these operations are processing client requests that arrive at unpredictable times and require an unpredictable amount of processing power. These requests usually arrive from I/O devices such as network adapters; processing the requests frequently requires additional I/O devices such as disk files.
In Microsoft Windows applications, threads are the best facility available to help you partition work. Each thread is assigned to a processor, which allows a multiprocessor machine to execute multiple operations simultaneously, increasing throughput. When a thread issues a synchronous device I/O request, the thread is temporarily suspended until the device completes the I/O request. This suspension hurts performance because the thread is unable to do useful work, such as initiate another client’s request for processing. So, in short, you want to keep your threads doing useful work all the time and avoid having them block.
To help keep threads busy, you need to make your threads communicate with one another about the operations they will perform. Microsoft has spent years researching and testing in this area and has developed a finely tuned mechanism to create this communication. This mechanism, called the I/O completion port, can help you create high-performance, scalable applications. By using the I/O completion port, you can make your application’s threads achieve phenomenal throughput by reading and writing to devices without waiting for the devices to respond.
The I/O completion port was originally designed to handle device I/O, but over the years, Microsoft has architected more and more operating system facilities that fit seamlessly into the I/O completion port model. One example is the job kernel object, which monitors its processes and sends event notifications to an I/O completion port. The Job Lab sample application detailed in Chapter 5, “Jobs,” demonstrates how I/O completion ports and job objects work together.
Throughout my many years as a Windows developer, I have found more and more uses for the I/O completion port, and I feel that every Windows developer must fully understand how the I/O completion port works. Even though I present the I/O completion port in this chapter about device I/O, be aware that the I/O completion port doesn’t have to be used with device I/O at all–simply put, it is an awesome interthread communication mechanism with an infinite number of uses.
From this fanfare, you can probably tell that I’m a huge fan of the I/O completion port. My hope is that by the end of this chapter, you will be too. But instead of jumping right into the details of the I/O completion port, I’m going to explain what Windows originally offered developers for device I/O. This will give you a much greater appreciation for the I/O completion port. In “I/O Completion Ports” on page 320 I’ll discuss the I/O completion port.
Opening and Closing Devices
One of the strengths of Windows is the sheer number of devices that it supports. In the context of this discussion, I define a device to be anything that allows communication. Table 10-1 lists some devices and their most common uses.
Table 10-1 Various Devices and Their Common Uses
Device |
Most Common Use |
File |
Persistent storage of arbitrary data |
Directory |
Attribute and file compression settings |
Logical disk drive |
Drive formatting |
Physical disk drive |
Partition table access |
Serial port |
Data transmission over a phone line |
Parallel port |
Data transmission to a printer |
Mailslot |
One-to-many transmission of data, usually over a network to a machine running Windows |
Named pipe |
One-to-one transmission of data, usually over a network to a machine running Windows |
Anonymous pipe |
One-to-one transmission of data on a single machine (never over the network) |
Socket |
Datagram or stream transmission of data, usually over a network to any machine supporting sockets (The machine need not be running Windows.) |
Console |
A text window screen buffer |
This chapter discusses how an application’s threads communicate with these devices without waiting for the devices to respond. Windows tries to hide device differences from the software developer as much as possible. That is, once you open a device, the Windows functions that allow you to read and write data to the device are the same no matter what device you are communicating with. Although only a few functions are available for reading and writing data regardless of the device, devices are certainly different from one another. For example, it makes sense to set a baud rate for a serial port, but a baud rate has no meaning when using a named pipe to communicate over a network (or over the local machine). Devices are subtly different from one another, and I will not attempt to address all their nuances. However, I will spend some time addressing files because files are so common. To perform any type of I/O, you must first open the desired device and get a handle to it. The way you get the handle to a device depends on the particular device. Table 10-2 lists various devices and the functions you should call to open them.
Table 10-2 Functions for Opening Various Devices
Device |
Function Used to Open the Device |
File |
CreateFile (pszName is pathname or UNC pathname). |
Directory |
CreateFile (pszName is directory name or UNC directory name). Windows allows you to open a directory if you specify the FILE_FLAG_BACKUP_SEMANTICS flag in the call to CreateFile. Opening the directory allows you to change the directory’s attributes (to normal, hidden, and so on) and its time stamp. |
Logical disk drive |
CreateFile (pszName is "\\.\x:"). Windows allows you to open a logical drive if you specify a string in the form of "\\.\x:" where x is a drive letter. For example, to open drive A, you specify "\\.\A:". Opening a drive allows you to format the drive or determine the media size of the drive. |
Physical disk drive |
CreateFile (pszName is "\\.\PHYSICALDRIVEx"). Windows allows you to open a physical drive if you specify a string in the form of "\\.\PHYSICALDRIVEx" where x is a physical drive number. For example, to read or write to physical sectors on the user’s first physical hard disk, you specify "\\.\PHYSICALDRIVE0". Opening a physical drive allows you to access the hard drive’s partition tables directly. Opening the physical drive is potentially dangerous; an incorrect write to the drive could make the disk’s contents inaccessible by the operating system’s file system. |
Serial port |
CreateFile (pszName is "COMx"). |
Parallel port |
CreateFile (pszName is "LPTx"). |
Mailslot server |
CreateMailslot (pszName is "\\.\mailslot\mailslotname"). |
Mailslot client |
CreateFile (pszName is "\\servername\mailslot\mailslotname"). |
Named pipe server |
CreateNamedPipe (pszName is "\\.\pipe\pipename"). |
Named pipe client |
CreateFile (pszName is "\\servername\pipe\pipename"). |
Anonymous pipe |
CreatePipe client and server. |
Socket |
socket, accept, or AcceptEx. |
Console |
CreateConsoleScreenBuffer or GetStdHandle. |
Each function in Table 10-2 returns a handle that identifies the device. You can pass the handle to various functions to communicate with the device. For example, you call SetCommConfig to set the baud rate of a serial port:
BOOL SetCommConfig( HANDLE hCommDev, LPCOMMCONFIG pCC, DWORD dwSize);
And you use SetMailslotInfo to set the time-out value when waiting to read data:
BOOL SetMailslotInfo( HANDLE hMailslot, DWORD dwReadTimeout);
As you can see, these functions require a handle to a device for their first argument.
When you are finished manipulating a device, you must close it. For most devices, you do this by calling the very popular CloseHandle function:
BOOL CloseHandle(HANDLE hObject);
However, if the device is a socket, you must call closesocket instead:
int closesocket(SOCKET s);
Also, if you have a handle to a device, you can find out what type of device it is by calling GetFileType:
DWORD GetFileType(HANDLE hDevice);
All you do is pass to the GetFileType function the handle to a device, and the function returns one of the values listed in Table 10-3.
Table 10-3 Values Returned by the GetFileType Function
Value |
Description |
FILE_TYPE_UNKNOWN |
The type of the specified file is unknown. |
FILE_TYPE_DISK |
The specified file is a disk file. |
FILE_TYPE_CHAR |
The specified file is a character file, typically an LPT device or a console. |
FILE_TYPE_PIPE |
The specified file is either a named pipe or an anonymous pipe. |
A Detailed Look at CreateFile
The CreateFile function, of course, creates and opens disk files, but don’t let the name fool you–it opens lots of other devices as well:
HANDLE CreateFile( PCTSTR pszName, DWORD dwDesiredAccess, DWORD dwShareMode, PSECURITY_ATTRIBUTES psa, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hFileTemplate);
As you can see, CreateFile requires quite a few parameters, allowing for a great deal of flexibility when opening a device. At this point, I’ll discuss all these parameters in detail.
When you call CreateFile, the pszName parameter identifies the device type as well as a specific instance of the device.
The dwDesiredAccess parameter specifies how you want to transmit data to and from the device. You can pass these four generic values, which are described in Table 10-4. Certain devices allow for additional access control flags. For example, when opening a file, you can specify access flags such as FILE_READ_ATTRIBUTES. See the Platform SDK documentation for more information about these flags.
Table 10-4 Generic Values That Can Be Passed for CreateFile’s dwDesiredAccess Parameter
Value |
Meaning |
0 |
You do not intend to read or write data to the device. Pass 0 when you just want to change the device’s configuration settings–for example, if you want to change only a file’s time stamp. |
GENERIC_READ |
Allows read-only access from the device. |
GENERIC_WRITE |
Allows write-only access to the device. For example, this value can be used to send data to a printer and by backup software. Note that GENERIC_WRITE does not imply GENERIC_READ. |
GENERIC_READ | GENERIC_WRITE |
Allows both read and write access to the device. This value is the most common because it allows the free exchange of data. |
The dwShareMode parameter specifies device-sharing privileges. It controls how the device can be opened by additional calls to CreateFile while you still have the device opened yourself (that is, you haven’t closed the device yet by calling CloseHandle). Table 10-5 describes the possible values that can be passed for the dwShareMode parameter.
Table 10-5 Values Related to I/O That Can Be Passed for CreateFile’s dwShareMode Parameter
Value |
Meaning |
0 |
You require exclusive access to the device. If the device is already opened, your call to CreateFile fails. If you successfully open the device, future calls to CreateFile fail. |
FILE_SHARE_READ |
You require that the data maintained by the device can’t be changed by any other kernel object referring to this device. If the device is already opened for write or exclusive access, your call to CreateFile fails. If you successfully open the device, future calls to CreateFile fail if GENERIC_WRITE access is requested. |
FILE_SHARE_WRITE |
You require that the data maintained by the device can’t be read by any other kernel object referring to this device. If the device is already opened for read or exclusive access, your call to CreateFile fails. If you successfully open the device, future calls to CreateFile fail if GENERIC_READ access is requested. |
FILE_SHARE_READ | FILE_SHARE_WRITE |
You don’t care if the data maintained by the device is read or written to by any other kernel object referring to this device. If the device is already opened for exclusive access, your call to CreateFile fails. If you successfully open the device, future calls to CreateFile fail when exclusive read, exclusive write, or exclusive read/write access is requested. |
FILE_SHARE_DELETE |
You don’t care if the file is logically deleted or moved while you are working with the file. Internally, Windows marks a file for deletion and deletes it when all open handles to the file are closed. |
The psa parameter points to a SECURITY_ATTRIBUTES structure that allows you to specify security information and whether or not you’d like CreateFile’s returned handle to be inheritable. The security descriptor inside this structure is used only if you are creating a file on a secure file system such as NTFS; the security descriptor is ignored in all other cases. Usually, you just pass NULL for the psa parameter, indicating that the file is created with default security and that the returned handle is noninheritable.
The dwCreationDisposition parameter is most meaningful when CreateFile is being called to open a file as opposed to another type of device. Table 10-6 lists the possible values that you can pass for this parameter.
Table 10-6 Values That Can Be Passed for CreateFile’s dwCreationDisposition Parameter
Value |
Meaning |
CREATE_NEW |
Tells CreateFile to create a new file and to fail if a file with the same name already exists. |
CREATE_ALWAYS |
Tells CreateFile to create a new file regardless of whether a file with the same name already exists. If a file with the same name already exists, CreateFile overwrites the existing file. |
OPEN_EXISTING |
Tells CreateFile to open an existing file or device and to fail if the file or device doesn’t exist. |
OPEN_ALWAYS |
Tells CreateFile to open the file if it exists and to create a new file if it doesn’t exist. |
TRUNCATE_EXISTING |
Tells CreateFile to open an existing file, truncate its size to 0 bytes, and fail if the file doesn’t already exist. |
CreateFile’s dwFlagsAndAttributes parameter has two purposes: it allows you to set flags that fine-tune the communication with the device, and if the device is a file, you also get to set the file’s attributes. Most of these communication flags are signals that tell the system how you intend to access the device. The system can then optimize its caching algorithms to help your application work more efficiently. I’ll describe the communication flags first and then discuss the file attributes.
CreateFile Cache Flags
This section describes the various CreateFile cache flags, focusing on file system objects. For other kernel objects such as mailslots, you should refer to the MSDN documentation to get more specific details.
FILE_FLAG_NO_BUFFERING
This flag indicates not to use any data buffering when accessing a file. To improve performance, the system caches data to and from disk drives. Normally, you do not specify this flag, and the cache manager keeps recently accessed portions of the file system in memory. This way, if you read a couple of bytes from a file and then read a few more bytes, the file’s data is most likely loaded in memory and the disk has to be accessed only once instead of twice, greatly improving performance. However, this process does mean that portions of the file’s data are in memory twice: the cache manager has a buffer, and you called some function (such as ReadFile) that copied some of the data from the cache manager’s buffer into your own buffer.
When the cache manager is buffering data, it might also read ahead so that the next bytes you’re likely to read are already in memory. Again, speed is improved by reading more bytes than necessary from the file. Memory is potentially wasted if you never attempt to read further in the file. (See the FILE_FLAG_SEQUENTIAL_SCAN and FILE_FLAG_RANDOM_ACCESS flags, discussed next, for more about reading ahead.)
By specifying the FILE_FLAG_NO_BUFFERING flag, you tell the cache manager that you do not want it to buffer any data–you take on this responsibility yourself! Depending on what you’re doing, this flag can improve your application’s speed and memory usage. Because the file system’s device driver is writing the file’s data directly into the buffers that you supply, you must follow certain rules:
You must always access the file by using offsets that are exact multiples of the disk volume’s sector size. (Use the GetDiskFreeSpace function to determine the disk volume’s sector size.)
You must always read/write a number of bytes that is an exact multiple of the sector size.
You must make sure that the buffer in your process’ address space begins on an address that is integrally divisible by the sector size.
FILE_FLAG_SEQUENTIAL_SCAN and FILE_FLAG_RANDOM_ACCESS
These flags are useful only if you allow the system to buffer the file data for you. If you specify the FILE_FLAG_NO_BUFFERING flag, both of these flags are ignored.
If you specify the FILE_FLAG_SEQUENTIAL_SCAN flag, the system thinks you are accessing the file sequentially. When you read some data from the file, the system actually reads more of the file’s data than the amount you requested. This process reduces the number of hits to the hard disk and improves the speed of your application. If you perform any direct seeks on the file, the system has spent a little extra time and memory caching data that you are not accessing. This is perfectly OK, but if you do it often, you’d be better off specifying the FILE_FLAG_RANDOM_ACCESS flag. This flag tells the system not to pre-read file data.
To manage a file, the cache manager must maintain some internal data structures for the file–the larger the file, the more data structures required. When working with extremely large files, the cache manager might not be able to allocate the internal data structures it requires and will fail to open the file. To access extremely large files, you must open the file using the FILE_FLAG_NO_BUFFERING flag.
FILE_FLAG_WRITE_THROUGH
This is the last cache-related flag. It disables intermediate caching of file-write operations to reduce the potential for data loss. When you specify this flag, the system writes all file modifications directly to the disk. However, the system still maintains an internal cache of the file’s data, and file-read operations use the cached data (if available) instead of reading data directly from the disk. When this flag is used to open a file on a network server, the Windows file-write functions do not return to the calling thread until the data is written to the server’s disk drive.
That’s it for the buffer-related communication flags. Now let’s discuss the remaining communication flags.
Miscellaneous CreateFile Flags
This section describes the other flags that exist to customize CreateFile behaviors outside of caching.
FILE_FLAG_DELETE_ON_CLOSE
Use this flag to have the file system delete the file after all handles to it are closed. This flag is most frequently used with the FILE_ATTRIBUTE_TEMPORARY attribute. When these two flags are used together, your application can create a temporary file, write to it, read from it, and close it. When the file is closed, the system automatically deletes the file–what a convenience!
FILE_FLAG_BACKUP_SEMANTICS
Use this flag in backup and restore software. Before opening or creating any files, the system normally performs security checks to be sure that the process trying to open or create a file has the requisite access privileges. However, backup and restore software is special in that it can override certain file security checks. When you specify the FILE_FLAG_BACKUP_SEMANTICS flag, the system checks the caller’s access token to see whether the Backup/Restore File and Directories privileges are enabled. If the appropriate privileges are enabled, the system allows the file to be opened. You can also use the FILE_FLAG_BACKUP_SEMANTICS flag to open a handle to a directory.
FILE_FLAG_POSIX_SEMANTICS
In Windows, filenames are case-preserved, whereas filename searches are case-insensitive. However, the POSIX subsystem requires that filename searches be case-sensitive. The FILE_FLAG_POSIX_SEMANTICS flag causes CreateFile to use a case-sensitive filename search when creating or opening a file. Use the FILE_FLAG_POSIX_SEMANTICS flag with extreme caution–if you use it when you create a file, that file might not be accessible to Windows applications.
FILE_FLAG_OPEN_REPARSE_POINT
In my opinion, this flag should have been called FILE_FLAG_IGNORE_REPARSE_POINT because it tells the system to ignore the file’s reparse attribute (if it exists). Reparse attributes allow a file system filter to modify the behavior of opening, reading, writing, and closing a file. Usually, the modified behavior is desired, so using the FILE_FLAG_OPEN_REPARSE_POINT flag is not recommended.
FILE_FLAG_OPEN_NO_RECALL
This flag tells the system not to restore a file’s contents from offline storage (such as tape) back to online storage (such as a hard disk). When files are not accessed for long periods of time, the system can transfer the file’s contents to offline storage, freeing up hard disk space. When the system does this, the file on the hard disk is not destroyed; only the data in the file is destroyed. When the file is opened, the system automatically restores the data from offline storage. The FILE_FLAG_OPEN_NO_RECALL flag instructs the system not to restore the data and causes I/O operations to be performed against the offline storage medium.
FILE_FLAG_OVERLAPPED
This flag tells the system that you want to access a device asynchronously. You’ll notice that the default way of opening a device is synchronous I/O (not specifying FILE_FLAG_OVERLAPPED). Synchronous I/O is what most developers are used to. When you read data from a file, your thread is suspended, waiting for the information to be read. Once the information has been read, the thread regains control and continues executing.
Because device I/O is slow when compared with most other operations, you might want to consider communicating with some devices asynchronously. Here’s how it works: Basically, you call a function to tell the operating system to read or write data, but instead of waiting for the I/O to complete, your call returns immediately, and the operating system completes the I/O on your behalf using its own threads. When the operating system has finished performing your requested I/O, you can be notified. Asynchronous I/O is the key to creating high-performance, scalable, responsive, and robust applications. Windows offers several methods of asynchronous I/O, all of which are discussed in this chapter.
File Attribute Flags
Now it’s time to examine the attribute flags for CreateFile’s dwFlagsAndAttributes parameter, described in Table 10-7. These flags are completely ignored by the system unless you are creating a brand new file and you pass NULL for CreateFile’s hFileTemplate parameter. Most of the attributes should already be familiar to you.
Table 10-7 File Attribute Flags That Can Be Passed for CreateFile’s dwFlagsAndAttributes Parameter
Flag |
Meaning |
FILE_ATTRIBUTE_ARCHIVE |
The file is an archive file. Applications use this flag to mark files for backup or removal. When CreateFile creates a new file, this flag is automatically set. |
FILE_ATTRIBUTE_ENCRYPTED |
The file is encrypted. |
FILE_ATTRIBUTE_HIDDEN |
The file is hidden. It won’t be included in an ordinary directory listing. |
FILE_ATTRIBUTE_NORMAL |
The file has no other attributes set. This attribute is valid only when it’s used alone. |
FILE_ATTRIBUTE_NOT_CONTENT_INDEXED |
The file will not be indexed by the content indexing service. |
FILE_ATTRIBUTE_OFFLINE |
The file exists, but its data has been moved to offline storage. This flag is useful for hierarchical storage systems. |
FILE_ATTRIBUTE_READONLY |
The file is read-only. Applications can read the file but can’t write to it or delete it. |
FILE_ATTRIBUTE_SYSTEM |
The file is part of the operating system or is used exclusively by the operating system. |
FILE_ATTRIBUTE_TEMPORARY |
The file’s data will be used only for a short time. The file system tries to keep the file’s data in RAM rather than on disk to keep the access time to a minimum. |
Use FILE_ATTRIBUTE_TEMPORARY if you are creating a temporary file. When CreateFile creates a file with the temporary attribute, CreateFile tries to keep the file’s data in memory instead of on the disk. This makes accessing the file’s contents much faster. If you keep writing to the file and the system can no longer keep the data in RAM, the operating system will be forced to start writing the data to the hard disk. You can improve the system’s performance by combining the FILE_ATTRIBUTE_TEMPORARY flag with the FILE_FLAG_DELETE_ON_CLOSE flag (discussed earlier). Normally, the system flushes a file’s cached data when the file is closed. However, if the system sees that the file is to be deleted when it is closed, the system doesn’t need to flush the file’s cached data.
In addition to all these communication and attribute flags, a number of flags allow you to control the security quality of service when opening a named-pipe device. Because these flags are specific to named pipes only, I will not discuss them here. To learn about them, please read about the CreateFile function in the Platform SDK documentation.
CreateFile’s last parameter, hFileTemplate, identifies the handle of an open file or is NULL. If hFileTemplate identifies a file handle, CreateFile ignores the attribute flags in the dwFlagsAndAttributes parameter completely and uses the attributes associated with the file identified by hFileTemplate. The file identified by hFileTemplate must have been opened with the GENERIC_READ flag for this to work. If CreateFile is opening an existing file (as opposed to creating a new file), the hFileTemplate parameter is ignored.
If CreateFile succeeds in creating or opening a file or device, the handle of the file or device is returned. If CreateFile fails, INVALID_HANDLE_VALUE is returned.