===== 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.