The idea of a queue is just conceptual to allow humans to describe the architecture in a way familiar to ordinary people. In code, there is no actual "queue". Because the tasks don't actually queue, they're listed.
In code what you actually have is a task group or set or collection (implemented as an array of tasks or a linked list of tasks.. basically a database of tasks). What actually do the queuing are the events.
The basic program flow is actually fairly simple to understand. The pseudocode looks something like this:
while(still_waiting_for(events)) {
execute_script()
current_event = wait_for(events)
current_task = search_related_task(tasks, current_event)
call_function(current_task.callback)
}
In the code above, there's no variable or data structure that represents the event queue. Instead we just have a variable that stores the list of all events we're listening to/waiting for. The actual event "queue" is normally managed by the OS and is invisible in our code.
The tasks
variable is all the tasks that are waiting for their respective events. Like I previously mention above, this is normally a set or collection of tasks. We call this the task queue but as you can see the tasks are not queuing (lining up) for the events. Instead when an event happens we search the for the corresponding task in tasks
.
Now, the data structure for different types of tasks may be slightly different (for example, timer tasks are usually handled very differently). So because C/C++ is a typed language one valid way to handle this fact is to have multiple task lists (task queues). Another reason might be that you may want certain type of tasks to be evaluated before waiting for any event. The obvious one being callbacks to setImmediate()
. In which case you have a separate task queue for setImmediate
callbacks to be processed before everything else:
while(still_waiting_for(events)) {
execute_script()
for (i=0; i<immediate_tasks.length; i++) {
immediate = immediate_tasks[i];
call_function(immediate.callback)
}
current_event = wait_for(events)
current_task = search_related_task(tasks, current_event)
call_function(current_task.callback)
}
The actual coding to implement this in C/C++ may be a bit confusing to a typical C programmer but should be fairly easy to understand for someone used to thinking asynchronously in javascript. The low level function for handling asynchronous events vary depending on what OS you're using (eg, poll/epoll on Linux, overlapped I/O on Windows, kqueue for BSD/Mac OS) and different javascript interpreters handle them in different ways (eg, libuv in Node.js which itself uses epoll or kqueue or overlapped I/O depending on which OS you compile Node.js on) but the basic way they work are similar.
I'm going to use the cross-platform (POSIX) select() function to demonstrate how the wait_for(events)
part actually work in real code:
while(1) {
retval = select(maxNumberOfIO, readableIOList, writableIOList, NULL, timeout);
if (retval == -1) {
perror("Invalid select()");
}
else if (retval) {
// Find which file descriptor triggered the event
for (i=0; i<maxNumberOfIO; i++) {
if (FD_ISSET(i, readableIOList) && readable_task_queue[i]) {
call_js_callback(readable_task_queue[i]);
}
if (FD_ISSET(i, writableIOList) && writable_task_queue[i]) {
call_js_callback(writable_task_queue[i]);
}
}
}
}
For the select()
system call, if you pass in 0
as the value of timeout
it will wait forever for an event to happen. This allows you to set a time limit for waiting in case you need to execute other code (eg. waiting for keyboard or mouse event which may not use the select()
system call to communicate with your process). This timeout also allows us a simple way to implement timer events like setTimeout
and setInterval
:
while(1) {
// Calculate value of timeout:
nearest_timer = find_nearest_timer(timer_task_queue);
gettimeofday(¤t_time, NULL);
now_millisecs = current_time.tv_sec*1000 + current_time.tv_usec/1000;
next_timeout_millisecs = nearest_timer.timeout - now_millisecs;
if (next_timeout_millisecs < 0) {
next_timeout_millisecs = 1; // remember, zero means wait forever
}
timeout.time_t = next_timeout_millisecs/1000;
timeout.suseconds_t = (next_timeout_millisecs%1000)*1000;
// Wait for events:
retval = select(maxNumberOfIO, readableIOList, writableIOList, NULL, timeout);
// Process event:
if (retval == -1) {
perror("Invalid select()");
}
else if (retval) {
// Find which file descriptor triggered the event
for (i=0; i<maxNumberOfIO; i++) {
if (FD_ISSET(i, readableIOList) && readable_task_queue[i]) {
call_js_callback(readable_task_queue[i]);
}
if (FD_ISSET(i, writableIOList) && writable_task_queue[i]) {
call_js_callback(writable_task_queue[i]);
}
}
}
else {
// If we reach here it means we've timed out.
// So call the timer callback:
call_js_callback(nearest_timer);
}
}
As you can see, because timers work differently from normal I/O events we use a separate timer event queue.
To get better familiarity with the logic flow you can try implementing a simple single-threaded server in C/C++ yourself using the select()
system call. I've done it several times. The first time as a homework assignment in college and one time when I was tasked with adding asynchronous I/O to the Ferite programming language.