As you all know, I've been working on rewriting ircu's event loop--in a
seperate branch, so as to avoid destabilizing the present alpha.  I finished
that work a few days ago, and began testing.  I am pleased to report that
I've managed to work out most of the major bugs in the overall event system,
and that it now appears quite stable.  I'll be checking a few more aspects
of the thing today--namely, signal handling--and probably adjust some of the
debug logging--this thing can dump millions of lines of debug logs when only
mildly active.  When I come back, I'll work on debugging the other engines,
then I'll begin the work of merging it into the main-line.  Once it's
merged, I have a few other TODO items--including writing documentation,
which I'm sure most of you are looking forward to.

I'm going to take this opportunity to describe the architecture of the new
events system.  The old code wasn't truly an event loop at all; it did some
timing things, then called to either a poll()- or select()-based routine
that went through the list of on-line clients, called poll() or select(),
then went through that list again.  The core event loop of the new system
is incredibly simple compared to that, although complexity has been
introduced elsewhere.

The event loop handles--er, events.  An "event" is a signal received by the
server, such as SIGTERM; a socket that's become readable or writable; or a
timer that has expired.  When one of these events occur, a structure is
allocated that describes the event, and a callback is called.  (There is
code that allows these events to be placed into a queue instead of being
called immediately; this is to allow for going with a threaded "work-crew"
model--in the distant future.)  The callback can do whatever actions are
necessary to process the event, such as calling accept() on the socket.
The event structures do contain pointers to a structure that describes the
thing on which they occurred; this structure is referred to as the
"generator."

There are three types of generators, as mentioned briefly above.  A struct
Signal describes a signal generator.  Whenever a signal is received, an
event of type ET_SIGNAL is generated and processed by the callback.  The
callback routine is not subject to the limited environment of the signal
handler, and so can safely call any function.  Needless to say, the only
three signals that actually do anything are HUP, INT, and TERM, and they do
what they've always done--they just do it better.

The second type of generator I'll describe is the struct Timer generator.
The code supports three types of timers: TT_ABSOLUTE timers expire on (or
possibly after) an exact time_t value; TT_RELATIVE timers expire after so
many seconds; and TT_PERIODIC timers expire after so many seconds, and are
then re-queued.  For example, if I added a TT_ABSOLUTE timer with an expire
time of 990799386, this timer would be run at Fri May 25 10:03:06 2001
(EDT).  If I added a TT_RELATIVE timer for 60 seconds, when the current
time is 990799386, it will expire 60 seconds later, at 990799446.  Finally,
if I added a TT_PERIODIC timer for 60 seconds, when the current time is
990799386, it will run at 990799446, 990799506, 990799566, and so on.  When
a timer expires, an event of type ET_EXPIRE is generated.  Since all of
these structures are allocated by the caller of timer_add() (or socket_add()
or signal_add()), when a timer is deleted or expires (and is not to be
re-added), an event of type ET_DESTROY will be generated to allow the
event callback to de-allocate the structure.

The third type of generator is the most complex--it is described by struct
Socket.  Sockets can be in one of several states.  A socket which has had
a connection initiated on it, such as an autoconnect to another server or a
connect to a remote identd, is in state SS_CONNECTING.  If the socket is a
listening socket, then it's in state SS_LISTENING.  A socket which has been
accepted from a listening socket, or on which a connection has completed, is
in state SS_CONNECTED--this is a normal, complete client (or server)
connection.  Finally, there's SS_DATAGRAM, for ordinary datagram sockets,
and SS_CONNECTDG for datagram sockets we've called connect() on.

The socket states are mostly for the convenience of the caller, but they do
have an effect on what event type is generated.  For instance, SS_CONNECTING
sockets generate ET_CONNECT events when the connection completes, and
SS_LISTENING sockets generate ET_ACCEPT when there's a connection to be
accepted.  The remaining three socket states simply generate ordinary
ET_READ and ET_WRITE events.

Sockets also have an event mask that indicates to the event loop which
events we're presently interested in.  We don't have to do anything special
for SS_CONNECTING or SS_LISTENING sockets--the event loop automatically
figures out what the correct indication for ET_CONNECT or ET_ACCEPT looks
like.  However, this event mask allows us to tell the engine that we're not
ready to accept data from the client yet (for instance).  It also means
that we don't have to check for writable indications unless we actually
have data to write.

The final piece of the architecture I want to cover is the engines.  The
engines are the core of the whole events system; they wrap around calls to
such functions as poll() and select().  If we want to support another type
of interface, say, FreeBSD's kqueue() interface, we just write a single .c
file that implements that engine.  The system, as written, has 4 engines--
poll(), select(), kqueue(), and one based on /dev/poll.  Moreover, there is
a fall-back behavior; if, say, the /dev/poll engine cannot be initialized,
for whatever reason, the event system will fall back to either a poll()- or
select()-based engine.

The engines are designed for high efficiency.  For instance, the poll()-
based engine allocates an array of struct pollfd as part of its
initialization; whenever you change a socket state or a socket event mask,
a callback in the engine makes the appropriate change to the appropriate
element of the struct pollfd array.  This means that the engine's event
loop need only loop through the elements in the struct pollfd array once;
the array it needs to feed to poll() has already been computed.  The
select()-based engine does something similar with fd_set's, and uses the
magic of C's structure copy to keep the statically maintained fd_set's from
being changed inside of select().  The kqueue()- and /dev/poll-based
engines don't even have to be fed new structures or fd_set's, as the
interfaces already have the same update features as the API for the
engines.

I have put a lot of work into coming up with and implementing this
architecture.  It's working quite well now on my test server right now.
If we can debug the kqueue()-based engine before releasing u2.10.11, we may
very well see the CPU load on FreeBSD-based irc servers drop quite
considerably, given the statistics on http://www.kegel.com/c10k.html.  I
hope you like it.
-- 
Kevin L. Mitchell <[EMAIL PROTECTED]>

Reply via email to