A different style of concurrent programming, known as event-based concurrency, whose roots are in the UNIX systems is often used in both GUI-based applications as well as some types of internet servers, such as node.js. Because only one event is being handled at a time, there is no need to acquire or release locks.

The approach is quite simple: you simply wait for something (i.e., an “event”) to occur; when it does, you check what type of event it is and do the small amount of work it requires.

It is based around a simple construct known as the event loop. Pseudocode for an event loop looks like this

while (1) {
	events = getEvents();
	for (e in events)
		processEvent(e);
}

The code that processes each event is known as an event handler. Deciding which event to handle next is equivalent to scheduling.

With that basic event loop in mind, we next must address the question of how to receive events. In most systems, a basic API is available, via either the select() or poll() system calls. This interface enables a program to check whether there is any incoming I/O that should be attended to. For example, a network application can use them to check whether any network packets have arrived.

There are a few other difficulties with the event-based approach that we should mention.

First, what if an event requires that you issue a system call that might block?

For example, imagine a request comes from a client into a server to read a file from disk and return its contents to the requesting client. To service such a request, some event handler will eventually have to issue an open() system call to open the file, followed by a series of read() calls to read the file. As we don’t use multiple threads in the event driven concurrency, we can’t exploit the overlap of I/O to fully use CPU. When the event loop blocks, the whole system sits idle, and thus is a huge potential waste of resources. Thus, no blocking calls are allowed in event-based systems.

To overcome this limit, many modern operating systems have introduced new ways to issue I/O requests to the disk system, referred to generically as asynchronous I/O, or AIO. These interfaces enable an application to issue an I/O request and return control immediately to the caller, before the I/O has completed. Additional interfaces even enable an application to determine whether various I/Os have completed. In systems without asynchronous I/O, the pure event-based approach cannot be implemented.

Second, if a program has tens or hundreds of I/Os issued at a given point in time, should it simply keep checking each of them repeatedly?

To remedy this issue, some systems provide an approach based on the interrupt. This method uses UNIX signals to inform applications when an asynchronous I/O completes, thus removing the need to repeatedly ask the system.

Third, event-based code is generally more complicated to write than thread-based code.

When an event handler issues an asynchronous I/O, it must package up some program state for the next event handler to use when the I/O finally completes, which is called manual stack management. For example, imagine that a thread-based server needs to read from a file descriptor (fd) and, once complete, write the data that it read from the file to a network socket descriptor (sd).

int rc = read(fd, buffer, size);
rc = write(sd, buffer, size);

When the read() finally returns, the code immediately knows which socket to write to because that information is on the stack of the thread.

However, if it is an event-based server, how does it know which socket to write when the AIO call informs us that the read is complete? One solution is to use an old programming language construct known as a continuation. The idea is rather simple: basically, record the needed information to finish processing this event in some data structure; when the event happens , look up the needed information and process the event. In this specific case, the solution would be to record the socket descriptor (sd) in some kind of data structure (e.g., a hash table), indexed by the file descriptor (fd).

Fourth, if you want to utilize multiple CPUs, the event server has to run multiple event handlers in parallel. When doing so, the usual synchronization problems arise. Thus, on modern multicore systems, simple event handling without locks is no longer possible.

Fifth, it does not integrate well with certain kinds of systems activity, such as paging. For example, if an event-handler page faults, it will block, and thus the server will not make progress until the page fault completes. Even though the server has been structured to avoid explicit blocking, this problem is still hard to avoid, which can lead to large performance problems.

Sixth, event-based code can be hard to manage over time. For example, if a routine changes from non-blocking to blocking, the event handler that calls that routine must also change to accommodate its new nature, by ripping itself into two pieces.