Memory Mapped Files
by Per B. Larsen
The term memory mapped file refers to the operating
systems ability to treat any file supplied by the user as an extension of
virtual memory. The Windows operating system's support for memory mapped
files appear to be somewhat under-appreciated by Delphi programmers -
probably because the VCL offers no object oriented encapsulation of this
functionality.
In this article, we will create some simple classes
for managing file and memory mappings, and look at some of the things you
can do with memory mapped files. For completeness, we will also discuss
some of the issues involved when deciding whether or not to use memory
mapped files in a given situation.
Virtual Memory
You are probably
familiar with the basic principle behind virtual memory, but here is a
quick refresher.
Windows lets you
allocate large areas of memory (up to 2GB per process) even though you
have much less real memory installed in your computer. This is possible
because Windows does not actually reserve physical memory to match your
request, but only the address interval where your memory should be.
Windows and the
CPU together manage memory in
pages. On all Intel platforms, the page size is 4Kb. When memory is first
allocated, Windows reserves the corresponding address space in its page
table and marks all pages in the new address interval as 'not present'.
The first time you actually try to access a certain section of this
memory, the CPU will generate a 'page fault' exception. This exception is
then trapped by Windows, which allocates a physical memory page, updates
the page table with the new 'present' status, and restarts the instruction
that caused the exception.
Of course, there might not be any free memory pages
available that Windows can use to satisfy the request. In that case,
Windows will select one of the memory pages that are already in use -
usually one that hasn't been accessed for a while. Windows writes its
contents to the swap file, and changes the page table to reflect the fact
that the content of the old page is now in the swap file.
Later, if the address for
the data now in the swap file is referenced, Windows will know to
restore the page's contents as part of the 'page fault' handling.
Mapping Files
For 32-bit applications, Windows extends its virtual
memory system to allow mapping of files other than the swap file. This is
useful because a lot of the things that are read into memory during
program execution are already on disk.
To see why, consider an example where your
application reads a large table from a disk file into a memory buffer.
Then, when you switch to a different application, the memory used for the
table is needed for other things and the table data is written to the swap
file. Later, when you return to the first application, the table data is
reloaded from the swap file.
Mapping the file with the table into memory saves the
step of writing it to the swap file - it can be reloaded just as well from
the original file.
In fact, Windows itself uses this mechanism to
dynamically load pages from executable files into memory - and for many
other internal tasks as well.
The Basics
The following
is a bare-bones Delphi example of how to map a file into memory:
var
FileHandle ,
MappingHandle : THandle;
FileSize : DWord;
Data : Pointer;
procedure MapFile;
begin
FileHandle := CreateFile(
'sample.dat',
GENERIC_READ,
0, {exclusive}
nil, {security}
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
0);
FileSize := GetFileSize(FileHandle,nil);
MappingHandle := CreateFileMapping(
FileHandle,
nil, {security}
PAGE_READONLY,
0, {High Dword of size}
FileSize,
NIL);
Data := MapViewOfFile(
MappingHandle,
FILE_MAP_READ,
0, {start of map}
0, {low dword}
FileSize); {Bytes to map}
end;
We open the file with the
CreateFile API by passing the name of the file to map plus a set of
arguments whose values are nearly always what is shown here. Check your
on-line help for further documentation of the options. There are far too
many to cover here.
The handle returned by CreateFile
is passed to CreateFileMapping along with the size of the file and some
default attributes. The last argument is an optional name of the file
mapping object. This can be used to share mappings among processes. We'll
get back to that later.
The
handle returned by CreateFileMapping is passed to the MapViewOfFile
API.
At the end of this code, we have a
pointer called Data that we can use to access the data in our 'sample.dat'
file - just as if the data had been read into a memory buffer.
When we are done, we need to free
the resources associated with the mapped file, like this:
procedure UnmapFile;
begin
UnmapViewOfFile(Data);
CloseHandle(MappingHandle);
CloseHandle(FileHandle);
end;
The code in this paragraph
illustrates the basic API calls needed for mapping files, but it lacks any
kind of error handling and is not very flexible. It turns out that the
process of mapping files is so similar between different applications that
it makes sense to encapsulate the functionality in a simple class, and
that is what we will do in the following section.
The TFileMapping class
A simple class
encapsulation of the API for mapping files into memory could look like
this:
unit FileMap;
interface
uses
Windows,
SysUtils;
type
TFileMapping = class
private
FileHandle : THandle;
MappingHandle : THandle;
FSize : DWord;
FData : Pointer;
public
constructor Create(FileName : string);
destructor Destroy; override;
property Data : Pointer read FData;
property Size : DWord read FSize;
end;
We pass the name
of the file we wish to map in Create, we access the data of the file via
the Data property, the Size property tells us the size of the mapping, and
Destroy closes the file mapping and releases all resources.
The code is fairly simple:
implementation
{ TFileMapping }
constructor TFileMapping.Create(FileName: string);
begin
FileHandle := CreateFile(
PChar(FileName),
GENERIC_READ,
0, {exclusive}
nil, {security}
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
0);
if FileHandle = INVALID_HANDLE_VALUE then
raise Exception.Create('Unable to open file');
FSize := GetFileSize(FileHandle,nil);
MappingHandle := CreateFileMapping(
FileHandle,
nil, {security}
PAGE_READONLY,
0, {High Dword of size}
FSize,
nil);
if MappingHandle = 0 then
raise Exception.Create('Unable to create file mapping');
FData := MapViewOfFile(
MappingHandle,
FILE_MAP_READ,
0, {start of map}
0, {low dword}
FSize); {Bytes to map}
if FData = nil then
raise Exception.Create('Unable to map view of file');
end;
destructor TFileMapping.Destroy;
begin
if FData <> nil then
UnmapViewOfFile(FData);
if MappingHandle <> 0 then
CloseHandle(MappingHandle);
if FileHandle <> 0 then
CloseHandle(FileHandle);
inherited Destroy;
end;
end.
Putting it to use
Let's use the
unit with the file mapping logic presented in the previous section to
build a simplistic hex file viewer.
1.
Create a new project with a blank form.
2.
Drop an OpenDialog on the form.
3.
Drop a panel on the form and align it to the top.
4.
Drop a button on the panel.
5.
Drop a label on the main form, set its AutoSize property to False,
and align it to the client.
6.
Set the label's font property to Courier New or another fixed-pitch
font.
7.
Double-click on the button and add the following code to the new
event handler:
procedure TForm1.Button1Click(Sender: TObject);
var
FileMapping : TFileMapping;
begin
if OpenDialog1.Execute then begin
FileMapping := TFileMapping.Create(OpenDialog1.FileName);
try
ShowPage(FileMapping);
finally
FileMapping.Free;
end;
end;
end;
8.
Add FileMap, the name of our mapping unit, to the uses list.
9.
Add the following code to the unit:
procedure TForm1.ShowPage(FileMapping : TFileMapping);
var
LineOffset,i,j : DWord;
C : PChar;
S : string;
Ch : Char;
LinesPerPage,CharsPerLine : DWord;
begin
with Label1 do begin
LinesPerPage := Height div Canvas.TextHeight('Wy') - 1;
CharsPerLine := ((Width div Canvas.TextWidth('W')) - 3) div 3;
end;
S := '';
{for each "line"}
for i := 0 to pred(LinesPerPage) do begin
LineOffset := i * CharsPerLine;
C := PChar(DWord(FileMapping.Data) + LineOffset);
{build line of hex values}
for j := 0 to pred(CharsPerLine) do
if LineOffset + j < FileMapping.Size then
S := S + IntToHex(byte((C + j)^), 2)
else
S := S + ' ';
S := S + ' ';
{build line of printable characters}
for j := 0 to pred(CharsPerLine) do begin
if LineOffset + j < FileMapping.Size then
Ch := char((C + j)^)
else
Ch := ' ';
if Ch >= ' ' then
S := S + Ch
else
S := S + '.';
end;
S := S + #13; {new line}
end;
Label1.Caption := S;
end;
10.
Copy the declaration for the ShowPage method header to the public
section of the form.
11.
Compile and Run.
Note how the ShowPage method lacks any kind of file
I/O code. It simply uses the Data property of the file mapping as a
pointer directly to the data in the file, and lets the operating system
handle the rest.
This example program only shows as much data from the
file as will fit on the form, starting from the first byte of the file.
Extending the programs capabilities to support multiple pages is pretty
much a simple matter of adding support for managing a page offsets, but
that is left as an exercise to the reader.
Using File Mappings To Share Data
Between Processes
In addition to being a convenient mechanism for
randomly accessing data in a disk file, memory mapped files have another
very important use, namely sharing data between applications.
Previously, we touched upon the last parameter to the
CreateFileMapping API function, which is lpName. In our code, so far,
we've passed nil
for the lpName argument, but passing a program defined string value
instead lets other applications open a file mapping on the same section of
memory as long as they know the name. This is in fact the Microsoft
recommended method for sharing data between multiple processes. Indeed,
several systems in Windows that must work across process boundaries, like
SendMessage, DDE, and OLE use memory mapped files internally.
So far, in our examples, we've been mapping a
physical file on disk into memory. However, for applications that share a
buffer between processes, it usually doesn't make sense to associate the
buffer with a particular disk file. Fortunately,
the CreateFileMapping function lets us pass the constant $FFFFFFFF
instead of a valid file handle, which will cause the mapping to be
associated with a section of the swap file. We must also supply a size
argument to let Windows know how big we want the buffer to be.
Here is the straight API code for creating a named
memory buffer:
var
Data : Pointer;
FileMappingHandle : THandle;
procedure CreateSharedMap(ShareName : string; BufferSize : DWord);
begin
FileMappingHandle := CreateFileMapping($FFFFFFFF, nil, PAGE_READWRITE, 0,
BufferSize, PChar(ShareName));
Data := MapViewOfFile(FileMappingHandle, FILE_MAP_ALL_ACCESS, 0, 0, 0);
end;
The arguments to CreateSharedMap are ShareName, which
is the name of the mapping object, and BufferSize, which determines the
size of the buffer to be created.
When we are done using the buffer, we must release
its associated resources:
procedure CloseSharedMap;
begin
UnmapViewOfFile(Data);
CloseHandle(FileMappingHandle);
end;
Once a process has created the named buffer, any
other process can create its own pointer to the same memory buffer using
the following code:
procedure OpenSharedMap(ShareName : string);
begin
FileMappingHandle := OpenFileMapping(FILE_MAP_ALL_ACCESS, False, PChar(ShareName));
Data := MapViewOfFile(FileMappingHandle, FILE_MAP_ALL_ACCESS, 0, 0, 0);
end;
Like the process that creates the file mapping, other
processes that open their own view to an existing file mapping must
release their resources by calling CloseSharedMap. The shared buffer
remains allocated until the last process closes its mapping and handle -
even if the process that first creates the buffer is closed first.
We can encapsulate the buffer sharing logic in a
simple Delphi class:
unit ShareMap;
interface
uses
Windows,
SysUtils;
type
TShareMapping = class
private
MappingHandle : THandle;
FSize : DWord;
FData : Pointer;
public
constructor Create(ShareName : string; BufferSize : DWord);
destructor Destroy; override;
property Data : Pointer read FData;
property Size : DWord read FSize;
end;
This class uses the same interface for the process
that creates the buffer and other processes. Create will attempt to open
an existing file mapping with the name passed in ShareName. If the attempt
fails, a new file mapping is created.
implementation
{ TShareMapping }
constructor TShareMapping.Create(ShareName: string; BufferSize: DWord);
begin
MappingHandle := OpenFileMapping(FILE_MAP_ALL_ACCESS, False, PChar(ShareName));
if MappingHandle = INVALID_HANDLE_VALUE then begin
MappingHandle := CreateFileMapping($FFFFFFFF, nil, PAGE_READWRITE, 0,
BufferSize, PChar(ShareName));
if MappingHandle = 0 then
raise Exception.Create('Unable to create file mapping');
end;
FData := MapViewOfFile(MappingHandle, FILE_MAP_ALL_ACCESS, 0, 0, 0);
if FData = nil then
raise Exception.Create('Unable to map view of buffer');
FSize := BufferSize;
end;
destructor TShareMapping.Destroy;
begin
if FData <> nil then
UnmapViewOfFile(FData);
if MappingHandle <> 0 then
CloseHandle(MappingHandle);
end;
end.
Simplifying Data Access
The two classes presented so far provide access to
the data in the file mapping by way of a public Data property, which uses
the generic Pointer type. This means that whenever you need to access data
in the buffer, the Data property must be cast to a typed pointer
appropriate for the actual data you are working with.
If your file mapping is an array of a specific type,
you can simplify the use of the mapping object by creating a custom
descendant which provides a typed, indexed property of the actual data
type you are using. Say we are working with a file of records where each
record has the following layout:
type
TMyRecord = packed record
Name : string[80];
Address : string[80];
City : string[50];
State : string[2];
Zip : string[8];
Country : string[30];
end;
We can then create a custom descendent of
TFileMapping which looks like this:
type
PMyRecord = ^TMyRecord;
TMyRecordMapping = class(TFileMapping)
private
function GetMyRecord(Index: Integer): PMyRecord;
function GetRecordCount: DWord;
public
property RecordCount : DWord read GetRecordCount;
property MyRecord[Index : Integer] : PMyRecord read GetMyRecord;
end;
RecordCount returns the number of records in the
file, MyRecord returns a pointer to a particular record in the file.
This is what the two get methods look like:
{ TMyRecordMapping }
function TMyRecordMapping.GetMyRecord(Index: Integer): PMyRecord;
begin
Result := Data;
inc(Result, Index);
end;
function TMyRecordMapping.GetRecordCount: DWord;
begin
Result := Size div sizeof(TMyRecord);
end;
This custom file mapping class lets us write nice, clean code like the
following example, which reads all records in the file and prints out the
name field for each one:
procedure TForm1.Button1Click(Sender: TObject);
var
MyRecordMapping : TMyRecordMapping;
i : Integer;
begin
MyRecordMapping := TMyRecordMapping.Create('MyRecords.dat');
try
for i := 0 to pred(MyRecordMapping.RecordCount) do
writeln(MyRecordMapping.MyRecord[i].Name);
finally
MyRecordMapping.Free;
end;
end;
Performance Issues
Mapping files into memory can greatly simplify the
design of applications that deal with files. The technique is especially
handy when you need to access the contents of a file in a fairly random
fashion. However, because memory mapped files are implemented as an
extension of the operating system's page fault handling, there are
situations where you should not use this technique.
Consider the case where you want to sort a file of
records. An obvious solution using memory mapped files would be to map the
file into memory and use QuickSort. This solution is likely to be very
slow, however. The reason is that the QuickSort algorithm tries to
minimize the number of exchanges, not the number of times each record is
accessed. Each page of the file will be accessed many times during the
sort process, and unless the entire file fits into free memory, all the
pages of the file will wind up being read several times from disk. There
are many sorting algorithms available that perform much better for this
kind of thing.
In general, memory mapped files work well for
applications where each section of the file is read only a few times - or
where only a minor portion of the file needs to be read into memory.
Things To Be Aware Of When
Sharing Buffers
When you share memory mapped files or buffers
between applications, you should realize that though different processes
can open a handle to and create a pointer to a shared buffer, the address
of the buffer will typically be different in each process. In other words,
the pointer is only valid in the process that creates it. What this means
is that you can't store pointers that are offsets into the buffer in one
process and use those pointers in other processes.
Note: Windows
95/98 peculiarity
It turns out
that this does in fact work under Windows 95 and Windows 98. For
performance reasons, these operating systems map memory files at the
same address in all processes, but that is undocumented behavior that
can change at any time in
the future, according to Microsoft.
Anther thing you
should be aware of is that you can't store huge strings or objects in a
shared buffer. The reason is that both huge string and object variables
are really pointers to memory allocated on the heap. Normally,
the Delphi run-time library handles this transparently, but if you
store a pointer variable in a shared buffer and try to access it from
another process, the address of the object will be either invalid or point
to a completely different area of memory.
Conclusion
Memory mappings can greatly simplify many kinds of
applications that deal with files, and it is the method of choice for
sharing data buffers among applications in Windows. This article does not
discuss every aspect of their use, but the code presented does cover the
basics and should provide a good starting point for expansion. Hopefully,
it will inspire to more widespread use of this technique among Delphi
programmers.
|