2020/12/15

2020 Perl Advent Calendar - Day 15

<< First | < Prev | Next >

Yesterday we took a deeper dive into the insides of one asynchronous IO system, Future::IO, to get an impression of the sorts of things that go into creating it. Before we conclude our 2020 update on the concept of futures, I'd like to do one more deep dive into some real-world application code, to show some more details of the insides of an implementation. Today we'll take a look inside Net::Async::HTTP.

This module has existed on CPAN for a long time. It certainly predates Future::AsyncAwait and the mechanisms to provide parser plugin modules that allow async/await syntax. It even predates the Future module. In fact, the initial version of futures, CPS::Future, was built partly as an experiment to provide Net::Async::HTTP with a way to cancel pending requests. As a result of this history, a desire to keep the module as lightweight as possible in dependencies, and to support as many older Perl versions as possible, it does not use async/await syntax in its implementation. Therefore parts of the code are somewhat more convoluted and harder to read or follow, as would be the case for a cleaner version built on more modern techniques.

Keep that in mind on today's exploration, as much of this will be a tour of what tricks are otherwise required, when async/await is not available. As with yesterday's overview of Future::IO, the point today is not necessarily to follow and understand what is going on, but rather to simply see the amount of work is required and the steps necessary to take when writing large asynchronous systems when async/await is not available.

The inner mechanism of Net::Async::HTTP which makes the whole thing work is actually in an internal class which represents a connection to one HTTP server, named Net::Async::HTTP::Connection. The actual method that starts the request/response cycle is called request. The full version is 150 lines long at current count, but for our purposes the basic outline is as follows.

sub request
{
    ...
    
    my $f = $self->loop->new_future;
    
    push @{$self->{request_queue}}, RequestContext(
        on_read => $self->_mk_on_read_header(...),
        f       => $f,
        ...
    );
    
    ...
    $self->write(join($CRLF, @headers) . $CLRF . $CRLF);
    $self->write($req->content);
    
    return $f;
}

This creates a new future instance, builds a context structure containing that (among other things), pushes this structure to an internal array, arranges for the request itself to be sent, and then returns the future instance to the caller, which propagates up to become the return value of the actual GET call. The context structure also captures many variables and state about the request process, because those will be needed later on when response data starts to arrive. In particular is the on_read field, which stores a code reference for actually handling the response.

The response half of the process is a little more distributed around a few places. Since the connection class is a subclass of IO::Async::Stream it has an on_read event which invokes a method of the same name, which is the start of the control path here. Its job is relatively tiny, in that it mostly just has to invoke the current on_read handler code for the topmost item in the request queue.

sub on_read
{
    my $self = shift;
    my ($buffref, $closed) = @_;

    while(my $head = $self->{request_queue}[0]) {
        my $ret = $head->on_read->($self, $buffref, $closed, $head);
        ...
    }
}

Initially, the on_read handler code is set up from the start of the request for handling a response header. This will be the anonymous sub returned by the _mk_on_read_header method. The code there begins:

sub _mk_on_read_header
{
    my $self = shift;
    ...
    
    return sub {
        my ($self, $buffref, $closed, $ctx) = @_;
        ...
        unless($$buffref =~ s/(.*?$CRLF$CRLF)//s) {
            return 0;
        }
        my $header = HTTP::Response->parse($1);
        my $content_length = $header->content_length;
        ...
        
        if(defined $content_length) {
            $ctx->on_read =
                $self->_mk_on_read_length($content_length, ...);
            return 1;
        }
        
        ...
    };
}

This will continue to read bytes of response until it encounters the double-CRLF sequence that marks the end of the header. At that point it will need to decide what kind of handler is best for the response content - whether it should just read until the filehandle is closed, or a fixed amount of content, or use chunked encoding. This choice is made by inspecting various headers, then calling one of three more _mk_on_read_* methods to generate a new on_read handler to put back into the context structure.

This part needs emphasising again, as it is the important central part of this whole "request context" technique. The anonymous function returned by _mk_on_read_header is only for reading the header part of the response. Once it has determined the appropriate way to read the body content of the response, it creates a new anonymous function for that instead, and stores it in the on_read field of the request context. This piece of mutable data, in effect, stores the running state of this particular request/response cycle. Having stored it there, it just returns back to the main on_read method, which will repeat its operation again, now with the new handler in place to read the response body content.

For example, the handler for reading a fixed length of content (from the Content-Length header) will maintain a count of the number of bytes remaining, counting down to zero:

sub _mk_on_read_length
{
    my $self = shift;
    my ($content_length, ...) = @_;
    
    return sub {
        my ($self, $buffref, $closed, $ctx) = @_;
        
        my $content = $$buffref;
        $$buffref = "";
        
        $content_length -= length $content;
        $response->add_content($content);
        
        if($content_length == 0) {
            $ctx->f->done($response);
        }
        
        return 0;
    };
}

Once the required amount of content has been received, this loop will finally finish. The response object, having finally been constructed and filled with content, is passed to the done method of the future in the request context object. This is the one that the original request method first created at the start of the cycle.

We've now taken a very brief, and very summarized tour of the code responsible for a single request/response cycle in the HTTP client. These code examples have been simplified and elided many details, such as content encoding or handling of streaming responses that don't have to store the full content in memory. Already we have seen a number of layers of code, and encountered places where the running state is stored in explicit data structures, rather than being implied by the position of execution within the code itself.

In summary - there's a lot of complex code here, all loosely connected together by passing around a context object. That context object stores a bunch of state - some of it mutable and changing over time - relating to this particular request/response cycle. The context object takes the place of simply storing the state of the program in normal lexical variables, as would be the case for synchronous code, or asynchronous code when using the async/await syntax. Lacking that syntax, we have had to reconstruct many of its abilities using other, less familiar techniques. If we had been able to use async/await then a lot of this code could be much improved, by moving a lot of the "machinery" parts out from custom explicit implementation and into regular Perl code.

<< First | < Prev | Next >

No comments:

Post a Comment