===== Event-Driven Programming =====
The conventional way to serve multiple connections simultaneously is simply
to fork: to serve each connection from a different process or thread. You
may have done this already in CS 118. The Apache web server uses a forking
model. Each Apache server process can handle one request at a time; in
order to handle concurrent requests, Apache forks a number of "spare
server" processes, to which it forwards requests. Performance isn't as bad
as it could be, since the spare servers are kept around (rather than forked
off on every request).
Multi-process servers have real problems, however. The multiple processes
have significant overhead. Since the processes are isolated, it is
difficult for them to share resources, such as a cache of recently-accessed
pages. Some of these problems can be mitigated by using threads instead of
processes, but that introduces synchronization issues.
In this lab, you will implement an operating system-like solution: make
this server serve multiple simultaneous connections using **nonblocking
I/O** and **event-driven programming**. All of the programming methods
have their advantages and disadvantages, but since event-driven programming
often requires less resources, it is used for the fasted servers out there.
In event-driven programming, all file descriptors are changed to use
non-blocking I/O instead of blocking I/O. This means that the server never
blocks because a file descriptor isn't ready; instead, the read or write --
whichever operation would have blocked -- will return immediately with an
error like ''EWOULDBLOCK''. The server can then skip any not-ready file
descriptors and move on to the next connection. The server blocks in only
one place, during a call to the ''poll()'' system call. ''poll()'' allows the
server to block until **any one of a set** of file descriptors becomes ready.
Event-driven programming introduces a couple of wrinkles.
* Event-driven programs must maintain application-level buffers of connection data. This is because the program might read a partial data item before it would block; the program must remember the partially-read data. A similar statement holds for writing.
* Event-driven programs tend to be broken into many small chunks, rather than large, easy-to-understand functions. This is because a single thread handles the processing of many connections ("cooperative multitasking"). To handle many tasks in one thread, it is easiest to break those tasks into separate functions and call each one when it is ready.
For example, here's some pseudocode for parsing the beginning of an HTTP
connection. The HTTP header is read from ''rfd'', and the response is
written to ''wfd''.
begin_connection(int rfd, int wfd) {
char header[BUFSIZ];
while (header is not complete)
read header from rfd; // might block
write response to wfd; // might block
}
In an event-driven program, we can't allow either ''read_header()'' or
''write_response()'' to block -- that would prevent other connections from
running! Our first, incorrect, attempt might look like this.
begin_connection_event(int rfd, int wfd) {
char header[BUFSIZ];
if (header is not complete) {
try to read header from rfd;
if (would block)
return;
}
try to write response to wfd;
if (would block)
return;
}
dispatch_loop() {
while (1) {
if (rfd or wfd is ready)
begin_connection_event(rfd, wfd);
}
}
This function detects cases that would block and returns early. When the
relevant file descriptor becomes ready again, a central dispatch loop will
call ''begin_connection_event()'' again so it can complete (or at least get
further along). This code is not correct, however. The ''header[]'' variable
is stored on the stack, so it will disappear when ''begin_connection_event()''
returns. But ''begin_connection_event()'' might return
before the header has been read! Real event-driven programs store buffer
variables like ''header[]'' on the //heap//, in a connection state object. For
example:
typedef struct connection {
char header[BUFSIZ];
int rfd;
int wfd;
} connection_t;
begin_connection_event(connection_t *conn) {
if (conn->header is not complete) {
try to read conn->header from conn->rfd;
if (would block)
return;
}
try to write response to conn->wfd;
if (would block)
return;
}
dispatch_loop() {
create conn;
while (1) {
for (each connection conn)
if (conn->rfd or conn->wfd is ready)
begin_connection_event(conn);
}
}
This is much better, since the ''conn->header'' variable persists across calls.
However, the dispatch loop will call ''begin_connection_event()'' too many
times. In the first phase, before ''conn->header'' is complete, the code
**never tries to write to ''conn->wfd''**. Therefore, it doesn't matter whether
or not ''conn->wfd'' is ready; we only care about ''conn->rfd''.
The fix for this is to separate the ''begin_connection_event()'' function into
multiple **states**, where different states wait on different collections of
events, or react differently to events. Each state corresponds to a single
**callback function**.
typedef struct connection {
char header[BUFSIZ];
int rfd;
int wfd;
int state;
} connection_t;
read_header_event(connection_t *conn) {
assert(conn->state == READ_HEADER);
try to read conn->header from conn->rfd;
if (conn->header is complete)
conn->state = WRITE_RESPONSE;
}
write_response_event(connection_t *conn) {
assert(conn->state == WRITE_RESPONSE);
try to write response to conn->wfd;
}
dispatch_loop() {
create conn, initial state is READ_HEADER;
while (1) {
for (each connection conn) {
if (conn->state == READ_HEADER
&& (conn->rfd ready for reading || error on conn->rfd))
read_header_event(conn);
else if (conn->state == WRITE_RESPONSE
&& (conn->wfd ready for writing || error on conn->wfd))
write_response_event(conn);
}
}
}
(The "error" cases are necessary in case the remote client closes the connection
prematurely. We assume that the ''read_header_event''/''write_response_event'' functions
will detect the error and do the right thing when they see the error; we just need to make
sure that in the main loop, we detect error states as well as "ready" states.)
The last step is to add a blocking step in the dispatch loop. This step
will block until any connection is ready. The relevant system call is
called poll. poll works by blocking on a list of file descriptors
(files, sockets, and so forth) and returns when at least one file
descriptor is ready: that is, when data arrives on a socket, or when a
socket becomes writable. The file descriptor list is represented by an
array of ''struct pollfd''s. The relevant fields are ''fd'', ''events'',
and ''revents'', and the relevant events are ''POLLIN'', ''POLLOUT'', and (sometimes) ''POLLHUP''.
See the ''poll'' man page for further details.
Poll-based operations are performed in the following manner:
* Add the file descriptors from which you want to read or write to the ''struct pollfd'' array and set the ''POLLIN'' and/or ''POLLOUT'' events.
* Call ''poll()'', passing the ''struct pollfd'' array, the number of elements in the array, and a timeout.
* Check to see which file descriptors are available by inspecting the ''revents'' field of each ''pollfd'' and call the relevant callback functions.
* Repeat.
NOTE: Since your program blocks when there is no work to do, your
program SHOULD NOT use all CPU time while idle or serving files.
The program ''top'' may be helpful here; top shows running processes
and the percent time each is using.
Event-driven programming is more complex than a threaded version, but has
several important advantages.
- Since the entire server is cooperatively scheduled, there are no synchronization issues! No locks, no nothing.
- This code uses very little resources and will perform well.
- Embedded system programming, device driver programming, and so forth must generally use an event-driven style since threads are not supported.