This module is the portable operating-system layer for a process. This chapter covers the file system, threads and processes, event loops, I/O devices, logging, shared libraries, plugins, tar archives, and the clock.
This chapter covers:
The systems programming library (Pt::System) provides functionality to identify, create, rename, move or remove files and directories. An iterator based API can be used to traverse through the entries in a directory. It can be used with the iterator based algorithms in the C++ standard library. The FileDevice is an implementation of the IODevice to read and write files.
The Pt::System::FileInfo class provides operations to query information about files and directories in the file system and to add, remove and modify them. FileInfo objects can be created with a path, are assignable, comparable and can be used as keys for e.g. std::map. The path needs not to refer to existing items in the file system, when a FileInfo object is constructed. It can be checked whether a file exists and what type of file it is, as shown in the following example:
Most operations are available as non-member functions, so it is not neccessary to create temporary FileInfo objects. Only the paths to files or directories are required to perform file system operations. The next example illustrates some of the non-member functions for file operations:
The code shown above creates a file, moves it to a new location and finally deletes it. If an operation fails, for example because the file could not be created, an exception of type AccessFailed is thrown. This is also the case for all other operations such as size(), createFile(), createDirectory(), resize() and remove(). The exception reports the name of the resource that could not be accessed.
The Pt::System::DirectoryIterator can be used to iterate over the contents of directories. It is created with a path to a directory and satisfies the requirements for a forward iterator. The iterator successivly reads the contents of the assoziated directory and returns a FileInfo when dereferenced. Like the stream iterators of the C++ standard library, it changes to a special state when the end of the directory is reached. This state is identical to a default constructed iterator, so instances thereof can serve as the iterator to the end of the directory.
The constructor throws an AccessFailed exception if the path is not a valid directory. The exception can be avoided by checking the path with FileInfo::type() first, to make sure it is valid. The DirectoryIterator can then be advanced to get the FileInfo for the next file in the directory, until the end of the directory contents is reached.
A Pt::System::FileDevice reads and writes files in the filesystem either synchronously or asynchronously. If used synchronously, it offers similar functionality like a std::fstream or the file I/O functions from the C library (fopen...). However, most applications need to perform input and output asynchronously, which makes using a FileDevice attractive. Since it inherits Pt::System::IODevice, it can be used as the endpoint of an Pt::System::IOStream or Pt::System::IOBuffer, respectively. If no buffering is required, a FileDevice can be used on its own, as shown in the following example:
An EventLoop is required for all asynchronous operations. This includes not only reading and writing, but also opening the file. The function beginOpen() begins to open a file and the signal returned by opened() is sent when the file was opened. It is connected to the slot shown in the next example:
The asynchronous open operation is ended by calling endOpen(), and a write operation is started with beginWrite() to write bytes from a buffer to the file. The signal outputReady() is sent, when data was written to the file. A slot is connected to that signal to handle output:
The signal is inherited from IODevice, so the signature of the slot needs a reference to a IODevice as parameter. The write operation is ended by endWrite(), which returns the number of bytes written to the file. This might be less than what was requested by beginWrite(), in which case another write operation has to be started.
All functions to begin or end asynchronous operations throw an exception of type Pt::System::IOError on failure. If IOErrors are not catched and handled in the slots, they will propagate through the EventLoop into the main() function and end the program. Normally, larger applications will need to process errors in the slots, so the EventLoop is not stopped.
A process can run more than one thread of control. Thread is the portable thread. Construction does not start it. start() runs a Callable or an EventLoop. The thread must be joined or detached before it is destroyed. AttachedThread joins in its destructor. DetachedThread runs without a waiter and destroys itself when the entry returns.
Mutex serializes access to shared data. It is not recursive: the same thread must not lock it again. MutexLock locks in the constructor and unlocks in the destructor, including during stack unwinding. RecursiveMutex allows the owning thread to lock again. ReadWriteMutex allows concurrent readers or one writer. SpinMutex is for short critical sections. Atomic integers are documented with the core module.
Condition waits while a Mutex or MutexLock is held. wait() unlocks, suspends the caller, and relocks when the wait ends. signal() wakes one waiter. broadcast() wakes all. Semaphore counts. wait() decrements when the count is positive. post() increments.
Queue is a thread-safe FIFO. get() blocks while the queue is empty. put() blocks when a maximum size is set and the queue is full. A maximum of zero means no limit.
Process starts another program from ProcessInfo. start() runs it. wait() joins it. Redirected stdin, stdout, and stderr are IODevice endpoints.
Thread is the portable thread. Construction does not start it. start() runs the Callable passed to the constructor or to init(), or it runs an EventLoop. The object must be joined or detached before it is destroyed. Destroying a running joinable thread is an error.
join() blocks until the entry returns. detach() lets the thread run without a waiter. After detach(), join() is not used. sleep() suspends the calling thread. yield() gives up the rest of the time slice. exit() leaves the current thread.
The callable must outlive a joinable thread. AttachedThread joins in its destructor. DetachedThread detaches in the constructor and destroys itself when the entry returns.
Mutex serializes access to shared data. lock() waits if another thread holds it. The same thread must not lock it again. The mutex must be unlocked when it is destroyed.
MutexLock locks in the constructor and unlocks in the destructor, including during stack unwinding.
Condition waits while a Mutex is held. wait() unlocks that mutex, suspends the caller, and relocks when the wait ends. A timed wait returns false on timeout. signal() wakes one waiter. broadcast() wakes all.
get() returns the next element and blocks while the queue is empty. put() appends an element and blocks when a maximum size is set and the queue is full. A maximum of zero means no limit. Raising the maximum may wake a thread blocked in put().
Construct with a ProcessInfo that names the command, arguments, and how stdin, stdout, and stderr are handled. start() runs the program. wait() joins it and returns the exit status. tryWait() reports whether it has finished. kill() terminates it. stdInput(), stdOutput(), and stdError() return the redirected IODevice endpoints when redirection was requested.
| SystemError | if start(), kill(), or wait() fails. |
| ProcessFailed | if wait() finds a non-normal termination. |
Application is the console process root. There is one instance. It uses an EventLoop. run() enters that loop. exit() leaves it. Command line arguments, environment variables, the working directory, and C signals are Application services.
EventLoop is the dispatch core of a thread or process. It is an EventSink. commitEvent() queues an event and wakes the loop. queueEvent() queues without waking. wake() starts processing. Events are delivered on eventReceived in the thread that called run(). MainLoop is the platform EventLoop.
A Selectable attaches with setActive so the loop can wait for it. detach() removes it and cancels outstanding operations. Timer is not a Selectable. It registers with setActive and emits timeout at an interval.
EventSource sends events to EventSink objects in other threads. Signal is not thread-safe. EventSource is.
Application is the console process root. There is one instance per process. It uses an EventLoop. The default constructors create a MainLoop. A custom loop can be passed in. The application does not own a loop passed by the caller.
run() enters the loop. exit() leaves it. loop() returns the loop so timers and selectables can attach to it. instance() returns the single application.
Command line arguments are available through argc(), argv(), and getArg(). catchSystemSignal() reports a C signal on systemSignal. ignoreSystemSignal() leaves a signal to the default handler. chdir(), cwd(), rootdir(), and tmpdir() are the process directories. setEnvVar(), unsetEnvVar(), and getEnvVar() change the environment.
EventLoop is the dispatch core of a thread or process. It monitors Selectable objects and Timer objects and delivers queued events. A process often runs one loop in the main thread. A second loop can run in another Thread.
The loop is an EventSink. commitEvent() queues an event and wakes the loop. queueEvent() queues without waking, so several events can be added and then released with one wake(). Events are delivered on eventReceived in the thread that called run(), in the order they were queued.
run() enters the loop and returns when exit() stops it. processEvents() delivers queued events without entering run(). exited is emitted when the loop leaves run(). Delivery always happens in the loop thread, including events queued from other threads.
Selectable is the attachment between an asynchronous operation and an EventLoop. setActive() attaches it. The loop then monitors the operation. detach() removes it and cancels work that is still running. cancel() cancels without detaching. parent() is the loop.
run() executes the operation when the loop has marked it ready. post() asks the loop to run it from any thread. IODevice and IONotifier are selectables. Timer is not; it registers with the loop on its own.
The loop does not own the selectable. The caller keeps it alive while it is attached.
Timer emits timeout at a fixed interval. It is not a Selectable. setActive() registers it with an EventLoop. start() begins the interval from the moment it is called. stop() ends it. timeout is not sent until the timer is registered and started.
The interval can be changed while the timer runs. start() again replaces it. detach() removes the timer from the loop. The destructor emits no timeout after that.
Signal is not thread-safe and is for use inside one thread. EventSource sends a Event to connected EventSink objects and synchronizes connect, disconnect, and send. An EventLoop is an EventSink, so a source in one thread can queue events for a loop in another.
connect() adds a sink. disconnect() removes it. subscribe() limits a sink to one event type. unsubscribe() removes that limit. send() delivers to every connected sink that accepts the type.
IODevice is the endpoint for I/O. It is a Selectable. read() and write() block. beginRead() and beginWrite() run with an EventLoop. endRead() and endWrite() complete those operations. inputReady and outputReady fire when an asynchronous operation finishes. FileDevice is the file endpoint.
Pipe is a pair of IODevice objects. Bytes written to in() are read from out() in the same order. SerialDevice is a serial port with baud rate, parity, stop bits, and flow control.
IOBuffer is the stream buffer for an IODevice. IOStream, IStream, and OStream attach a device through that buffer.
IONotifier waits on a native handle or file descriptor in an EventLoop.
IODevice is the endpoint for I/O. It is a Selectable. A file, a pipe end, and a serial port are IODevice types. I/O buffers and I/O streams use this endpoint, so a standard C++ stream can be built at runtime.
read() and write() transfer bytes and may return fewer than requested. They throw IOError on failure. When the device reaches the end of the stream, isEof() is true. setTimeout() limits how long a blocking transfer waits. close() releases the endpoint.
beginRead() and beginWrite() start an asynchronous transfer. The device must be attached to an EventLoop with setActive(). When the transfer finishes, inputReady or outputReady is emitted. endRead() and endWrite() complete the operation and return the number of bytes. Only one read and one write may run at a time.
Some devices can seek. seekable() reports that. seek() and position() throw IOError when the device cannot seek. peek() copies bytes without consuming them. sync() commits written data to the device.
IOBuffer is a std::streambuf that reads and writes an IODevice. attach() binds the buffer to a device. detach() unbinds it. discard() drops buffered bytes. reset() discards and detaches.
beginRead() fills the get area from the device. beginWrite() drains the put area to the device. Those transfers run through the device's EventLoop. inputReady and outputReady fire when a transfer finishes. endRead() and endWrite() complete it. isReading() and isWriting() report a transfer in progress.
IStream, OStream, and IOStream own an IOBuffer. Formatted extraction and insertion then use the attached device.
IOStream is a C++ iostream whose buffer is an IOBuffer. attach() binds an IODevice. Extraction and insertion then use that device. ioBuffer() returns the buffer so beginRead() and beginWrite() can run through the same EventLoop as the device.
The stream does not own the device. The caller keeps the device alive while the stream is attached. detach() unbinds it. discard() drops buffered bytes. reset() discards and detaches.
A pipe is two IODevice objects created together. Bytes written to in() are read from out() in the same order. The constructor opens both ends. The destructor closes them.
Whether a write blocks until a read consumes data is system-dependent. Some platforms buffer a limited number of bytes between the ends. That buffer must not be assumed. Treat a write as complete only when write() or endWrite() returns.
Each end is a Selectable. Attach out() to an EventLoop to read without blocking the thread. Attach in() to write the same way.
SerialDevice opens a serial port as an IODevice. The path is system-dependent, for example "COM1" on Windows or "/dev/ttyS0" on POSIX. Open the port, then set baud rate, character size, stop bits, parity, and flow control before read or write.
The device supports the blocking and asynchronous transfers of IODevice. Control lines are available as setRts(), setDtr(), isCts(), and isDsr(). setBreak() and sendBreak() generate a break condition.
IONotifier is a Selectable that reports activity on a native endpoint the rest of the I/O API does not own. setHandle() names a Windows handle. setFd() names a POSIX file descriptor. The constructor can pass either.
Attach the notifier to an EventLoop with setActive(). beginWait() starts monitoring for a combination of WaitFlags. When the loop sees activity, eventReady is emitted. endWait() returns the flags that are ready. reset() clears the handle or descriptor.
Use this type when the endpoint is not an IODevice: a socket created outside Pt, or a handle from another library.
The logging framework offers an efficient, extensible system to log messages from programs with multiple threads to a number of channels. Logging can be completely disabled at compile time, when the logging macros are used. At runtime, log messages are filtered by a level of severity. Filtering is very efficient, because log messages are not even built if their log level is too low. Currently three types of output channels exist, logging to files with file rolling, to the console and to the serial port. The logging framework can be extended by new channels.
Logging is an important feature of many applications. It can be used for debugging during the development process and to trace how a program executes once it is deployed. Crucial features of a logging framework are:
The heart of the logging framwork is a hierarchy of log targets, which have a unique string ID. Applications format log records and use logger objects to write them to a target. Each target is configured with a threshold log level. Only records that are equally or more severe than the threshold are logged. In this case, the log records are written to the targets channel. Targets can log to the same channel, in fact, often all targets log to the same channel. The log channels perform output of log records i.e. to the console, if a console channel is selected or to a file if a file channel is selected, respectively. The threshold log level and channel of each log target in the hierarchy can be configured at runtime. Here is a typical example:
<root> id: ""
| channel: console://
| level: info
|
|
<app> id: "app"
| channel: INHERIT
| level: INHERIT
|
|
.--------------------+-------------------.
| |
| |
<module1> id: "app.module1" <module2> id: "app.module2"
channel: file:///log.txt channel: INHERIT
level: trace level: INHERITIn this setup, three log targets are created. The root target is always persent and has the special empty string ID. The root target has one direct child, the target named "app". In this case, no log channel and level have been explicitly set for the target, so it inherits the attributes from its parent. The target "app" has two children, named "app.module1" and "app.module2". The string IDs indicate the position (path) of the target in the hierarchy. The target "app.module2" inherits all attributes from it parent, while the target "app.module1" overrides the log channel and level. With this mechanism based on inheritance, the parts of interest of the hierarchy can be enabled, while other parts of the hierarchy are suppressed.
Applications can use the API to set the threshold log levels and the channels of the targets. Possible log levels are:
Channels are configured by a channel URL. Possible channel URLs are:
The first opens a file channel writing to the file mylog.log. If the file size of 1000000 bytes is reached it will be renamed, and logging continues to a newly created mylog.log file. The parameter 'files' limits file rolling to a total number of five files. The second URL opens a channel to log to the console.
The format of the logging records can be configured with a format pattern string. The format pattern can contain text and specifiers, which are placeholders for the various elements of the log records. Specifiers are escaped with a percent sign in the format pattern string. For example, the pattern "%t %m" would write the time and the message for each log record separated by a space.
Here is a list of possible specifiers:
TODO:
The following code example above changes the log level and channel of the target named "app" to write records with a threshold level of Pt::System::Info to a log file:
A log record pattern is applied to print the target ID, the time and the message text of the records to the logs.
The Logger is the central class of the logging framework on the client side. It is used to write log records to a logging target maintained by the logging framework. A logger is created with a category string that identifies it's target to log to. The category string is in dot syntax, so the category string "app.module" refers to the target named "module", which is a child of the target named "app", which is a child of the root target. See LogTarget for more information.
If the target of a logger does not exist yet, it will be created. If several loggers are created with the same target string they will indeed use the same target. The creation of a logger requires a target lookup in the logging manager, so it is beneficial to keep created loggers at the class level for as long as they are needed. The logger provides a set of static methods to configure targets. The following code configures a log level and a channel for a target named "app.module":
Channels and log levels can either be assigned by the API or in the settings file that is loaded by calling Pt::System::Logger::init. Here is an example of a settings file:
In the example, pong would write messages with a log level of Trace or higher to the console channel, which it inherited from its parent. The target ping writes messages with a log level of Error of higher to a file.
The main purpose of the logger is to route log records to its target. If the log level of the record is less severe than the current log level of the target, the logger will discard the record. If a target has a log level of Info, the logger will reject records with the levels Trace or Debug. See LogRecord and LogMessage for more information.
Macros are provided to make logging more convenient. The easiest way to log is to use the logging macros for a static logger instance. A logger instance can be defined for a compilation unit and then logged to:
The advantage of the macros is that they will expand to nothing if NLOG is defined, so logging support can be conditionally compiled. Macros exist for the various log levels:
The disadvantage of these macros is that they do not allow more than one logger instance per compilation unit and the logger definition can not be placed in a public header file. A similar set of macros allows to define global logger instances:
Again, these logging statements can be conditionally compiled and the logging macros expand to nothing if NLOG is defined. Here is a list of the macros for the various log levels:
The alternative to using the macros is to use logger objects directly in the code. To do so, a Logger object must be instanciated, for example as a class member variable and then messages can be written to it:
Note that the code above is somewhat inefficient, because the log record is formatted even if its log level is below the threshold of the logger target. It is more efficient if the log level is checked before the record is formatted:
The library offers a set of macros that can be used with logger instances and implement this check:
Here is the list of the available macros:
Expansion of these macros is not affected by defining NLOG. These macros can not be used with loggers defined by the previous macros, because construction of global loggers is not trivial when the the static initialization fiasco has to be avoided. Definition of global loggers with the macros does not simply result in global variables.
Log messages can be used to log records with a specific logger. They maintain a log record and a reference to a logger. The log record text can be formatted with the stream output operator, just like for the log records itself:
To avoid the costs for formatting, it can be checked if the log level is enabled for the target. A log message can be send mutliple times, so formatting has to be done only once and the logging performance can be increased.
Although it generates more code, it can still make sense to work with log message and log record objects directly. These can improve performance, if the same record has to be logged multiple times, because formatting has to be done only once:
Log records represent the text entries that can be added to log. Each log record has a log level, which indicates it's severity. The text of a log record can be formatted with the stream operator, just like writing to std::cout. All stream output operators defined for std::ostream can be used including the manipulators.
Once a log record is created it can be added to a log with a logger. The record might be ignored if the log level is disabled for the logger's target. Unneccessary formatting can be avoided by checking if the record's log level is enabled for the logger's target.
The same log record can be sent multiple times to a logger or to several loggers. This way formatting is only done once and logging performance can be increased.
Similarily, a log record can be sent multiple times to a logger or to several loggers:
A process can load a shared library after it has started and resolve symbols from it. Library is the portable loader. It opens a library image and returns symbols by name.
The path "MyLib" is a basename. Construction and open() look for a file at the given path. If none is there, the path is extended by the platform suffix, then by the shared-library prefix. A basename is enough. An AccessFailed exception is thrown when no image is found. A second open() may close the image loaded before.
prefix() and suffix() return the platform naming pieces so a portable library name can be built without relying on that search. suffix() is ".so" on Linux and ".dll" on Windows. prefix() is "lib" on Linux and empty on Windows.
getSymbol() returns a Symbol when the name exists. It throws SymbolNotFound otherwise. The address is Symbol::sym(). A Symbol holds the Library that produced it, so the image stays loaded while the symbol exists.
The index operator and resolve() return a void pointer, or a null pointer when the name is missing. They do not throw. getSymbol() treats a missing name as an error. The index operator leaves that test to the caller.
Standard C++ does not allow a cast from a void pointer to a function pointer. Nearly all runtimes implement that as an extension. The caller casts Symbol::sym() to a function pointer to call it.
A plugin is a shared library. The application keeps the interface type. The concrete class is compiled into the library. No extra base class is required beyond that interface.
The library exports a null-terminated array of PluginId named PluginList. The export uses C linkage so the loader can resolve a stable symbol. PluginManager looks up that name through a Library.
Each entry is a Plugin that creates and destroys instances of one interface. BasicPlugin is the usual plugin. The first template argument is the concrete class. The second is the interface it implements. It constructs with new and destroys with delete.
The constructor takes a feature string that names the instance for later construction. The address of the BasicPlugin is placed in PluginList.
Several plugins may share one PluginList. A second concrete class for the same interface is another BasicPlugin in the same array.
A plugin that needs another allocator derives from Plugin and overrides create and destroy.
PluginManager loads a PluginList through a Library and creates instances by feature. The application loads and unloads plugins through the manager.
The instance is created and destroyed through the manager, not with delete. Loaded libraries unload when the manager is destroyed.
Plugin is one entry in a PluginList. The template argument is the interface it implements.
PluginId names the interface type, the feature string, and an info string. feature() is the name used later to construct an instance. info() is an optional description.
create() returns a new instance. destroy() releases that instance. They are the allocator for objects that come from the plugin.
PluginManager registers matching plugins and creates instances by feature.
PluginManager loads a PluginList from a Library and creates instances of one interface. The first template argument is that interface.
loadPlugin() opens the library and resolves the export name. The name PluginList is the usual export. Another symbol name is valid if the library exports that array.
A loaded plugin is registered when its interface matches the manager. Entries for other interfaces in the same PluginList are skipped. Plugins that already live in the process can be registered with registerPlugin(). They do not require a shared library.
destroy() must be used instead of delete. The allocator in the library may differ from the application. Creation and deletion stay on the same plugin. Remaining instances are destroyed and loaded libraries are unloaded when the manager is destroyed. Instance lifetime must not exceed the manager.
Iteration walks the loaded plugins without creating instances. create() also accepts an iterator from that walk.
A tar archive is a sequence of entries. TarReader parses that sequence from a std::istream. TarWriter writes it to a std::ostream. TarEntry is the current entry.
TarReader::advance() delivers one header or the next content chunk. A null return means the stream has no more bytes yet. The returned TarEntry stays valid until the next advance(). Process the bytes at data() before calling advance() again. isEnd() on the entry means the content is complete. isEnd() on the reader means the archive is complete. A non-zero import size may block and is for file streams. A zero import size uses only buffered bytes and is for an event loop.
TarWriter writes complete entries with addFile(), addDirectory(), addSymlink(), and addHardlink(). Large files use beginFile(), writeFile(), and endFile(). finish() writes the end-of-archive marker. Paths are UTF-8. Pax extended headers are written when a path is long or not ASCII.
A TarEntry holds the metadata and provides access to the content of one entry in a tar archive. Instances are produced by TarReader::advance() and remain valid until the next advance() call.
Use type() to distinguish files, directories, symbolic links, and hard links. Use path() for the archive path, mtime() for the modification time, and permissions() for the POSIX permission bits.
File content is delivered in one or more chunks. After each advance() call, up to avail() bytes are readable at data(). Process those bytes before calling advance() again — the buffer is reused on the next call. Repeat until isEnd() returns true, which means all size() bytes have been delivered.
TarReader parses a tar archive from a std::istream one entry at a time. It is designed for non-blocking use: advance() delivers only the bytes already in the stream buffer and returns nullptr when the stream is starved.
Call advance() in a loop. A non-null return value holds a TarEntry with the current entry's metadata and the first content chunk. Read TarEntry::data() for TarEntry::avail() bytes, then call advance() again to fetch the next chunk. Repeat until TarEntry::isEnd() is true, then call advance() once more to move to the next archive entry. The loop ends when isEnd() on the reader itself returns true.
Pass a non-zero importSize to advance() to read more bytes from the stream per call — this may block and is suitable for file-based use.
Pax extended headers handle long paths (> 99 characters), UTF-8 paths, and extended modification times automatically.
TarWriter writes entries sequentially into a tar archive. All operations are synchronous and write directly to the attached std::ostream.
Use addFile(), addDirectory(), addSymlink(), or addHardlink() to write complete entries in a single call. For large files, use the streaming API: beginFile() writes the header, writeFile() delivers the content in one or more chunks, and endFile() closes the entry.
Always call finish() before closing the stream to write the mandatory end-of-archive marker. Paths are interpreted as UTF-8; Pax extended headers are written automatically for long or non-ASCII paths.
Clock measures elapsed time and reports the system clock. start() begins a measurement. stop() returns the Timespan since start(). A second start() begins a new measurement.
getSystemTime() returns the current UTC time as a DateTime. getLocalTime() returns the local wall time. These values follow the system clock, including adjustments.
getSystemTicks() returns the timespan since a fixed point in the past. That origin is platform-defined. It may be boot time or an epoch. Differences between two tick values are monotonic and are the right way to measure elapsed time across threads.