`
cywhoyi
  • 浏览: 421250 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

Servlet 3.0 Async Processing for Tenfold Increase in Server Throughput

 
阅读更多

Servlets are the main component for handling server side logic in Java and the new3.0 specification introduces some very interesting features with asynchronous processsing being one of the most important. Async processing can be leveraged in order to develop highly scalable web applications. Web 2.0 sites and AJAX applications can be efficiently built with this feature.

 

Tomasz Nurkiewicz, one of our JCG partners, has recently written a very nice article on how to use async processing in order to increase your server’s throughput. Let’s find out how he did it.

(NOTE: The original post has been slightly edited to improve readability)

It is not a secret that Java servlet containers aren’t particularly suited for handling large amount of concurrent users. Commonly established thread-per-request model effectively limits the number of concurrent connections to the number of concurrently running threads JVM can handle. And because every new thread introduces significant increase of memory footprint and CPU utilization (context switches), handling more than 100-200 concurrent connections seems like a ridiculous idea in Java. At least it was in pre-Servlet 3.0 era.

In this article we will write a scalable and robust file server with throttled speed limit. In a second version, leveraging the Servlet 3.0 asynchronous processing feature, we will be able to handle ten times bigger load using even less threads. No additional hardware required, just few wise design decisions.

Token bucket algorithm

Building a file server, we have to consciously manage our resources, especially network bandwidth. We don’t want a single client to consume the whole traffic, we might even want to throttle the download limit dynamically at runtime, based on user, time of the day, etc. – and of course everything happens during heavy load. Developers love reinventing the wheel, however all our requirements are already addressed by the simple token bucket algorithm.

The explanation in Wikipedia is pretty good, but since we’ll adjust the algorithm a bit for our needs, here’s even simpler description. First there was a bucket. In this bucket there were uniform tokens. Each token is worth 20 kiB (I will be using real values from our application) of raw data. Every time a client asks for a file, the server tries to take one token from the bucket. If it succeeds, he sends 20 kiB to the client. Repeat last two sentences. What if the server fails to obtain the token because the bucket is already empty? He waits.

So where are the tokens coming from? Background process fill the bucket from time to time. Now it becomes clear. If this background process adds 100 new tokens every 100 ms (10 times per second), each worth 20 kiB, the server is capable of sending 20 MiB/s (100 times 20 kiB times 10) max, shared amongst all the clients. Of course if the bucket is full (1000 tokens), new tokens are ignored. This works amazingly well – if bucket is empty, clients are waiting for next bucket filling cycle; and by controlling the bucket capacity we can limit total bandwidth.

Enough talking, our simplistic implementation of token bucket starts with an interface (whole source code is available on GitHub in global-bucket branch):

01 public interface TokenBucket {
02  
03     int TOKEN_PERMIT_SIZE = 1024 20;
04  
05     void takeBlocking() throws InterruptedException;
06     void takeBlocking(int howMany) throws InterruptedException;
07  
08     boolean tryTake();
09     boolean tryTake(int howMany);
10  
11 }

takeBlocking() methods are waiting synchronously for the token to become available, while tryTake() are taking token only if it is available, returning true immediately if taken, false otherwise. Fortunately, the term bucket is just an abstraction: because tokens are indistinguishable, all we need to implement as bucket is an integer counter. But because the bucket is inherently multi-threaded and some waiting is involved, we need more sophisticated mechanism. Semaphore seems to be almost ideal:

01 @Service
02 @ManagedResource
03 public class GlobalTokenBucket extends TokenBucketSupport {
04  
05     private final Semaphore bucketSize = new Semaphore(0false);
06  
07     private volatile int bucketCapacity = 1000;
08  
09     public static final int BUCKET_FILLS_PER_SECOND = 10;
10  
11     @Override
12     public void takeBlocking(int howMany) throws InterruptedException {
13         bucketSize.acquire(howMany);
14     }
15  
16     @Override
17     public boolean tryTake(int howMany) {
18         return bucketSize.tryAcquire(howMany);
19     }
20  
21 }

Semaphore fits exactly to our requirements. bucketSize represents current amount of tokens in the bucket. bucketCapacity on the other hand limits the bucket maximum size. It is volatile because it can be modified via JMX (visibility):

01 @ManagedAttribute
02 public int getBucketCapacity() {
03     return bucketCapacity;
04 }
05  
06 @ManagedAttribute
07 public void setBucketCapacity(int bucketCapacity) {
08     isTrue(bucketCapacity >= 0);
09     this.bucketCapacity = bucketCapacity;
10 }

As you can see I am using Spring and its support for JMX. Spring framework isn’t absolutely necessary in this application, but it brings some nice features. For instance implementing a background process that periodically fills the bucket looks like this:

1 @Scheduled(fixedRate = 1000 / BUCKET_FILLS_PER_SECOND)
2 public void fillBucket() {
3     final int releaseCount =
4  min(bucketCapacity / BUCKET_FILLS_PER_SECOND,
5   bucketCapacity - bucketSize.availablePermits());
6     bucketSize.release(releaseCount);
7 }

This code contains major multi-threading bug that we can ignore for the purposes of this article. It is suppose to fill the bucket up to the maximum value – will it always work?

Moreover, here is the XML snippet (applicationContext.xml) required to make @Scheduled annotation work:

01 <?xml version="1.0" encoding="UTF-8"?>
03        xmlns:context="http://www.springframework.org/schema/context"
08  
09         <context:component-scan base-package="com.blogspot.nurkiewicz.download" />
10         <context:mbean-export/>
11  
12         <task:annotation-driven scheduler="bucketFillWorker"/>
13         <task:scheduler id="bucketFillWorker" pool-size="1"/>
14  
15 </beans>

Having token bucket abstraction and very basic implementation we can develop the actual servlet returning files. I am always returning the same fixed file with size of almost 200 kiB):

01 @WebServlet(urlPatterns = "/*", name="downloadServletHandler")
02 public class DownloadServlet extends HttpRequestHandlerServlet {}
03  
04  
05 @Service
06 public class DownloadServletHandler implements HttpRequestHandler {
07  
08     private static final Logger log =
09         LoggerFactory.getLogger(DownloadServletHandler.class);
10  
11     @Resource
12     private TokenBucket tokenBucket;
13  
14     @Override
15     public void handleRequest(HttpServletRequest request,
16        HttpServletResponse response) throws ServletException, IOException {
17  
18         final File file = new File("/home/dev/tmp/ehcache-1.6.2.jar");
19         final BufferedInputStream input =
20              new BufferedInputStream(new FileInputStream(file));
21         try {
22             response.setContentLength((int) file.length());
23             sendFile(request, response, input);
24         catch (InterruptedException e) {
25             log.error("Download interrupted", e);
26         finally {
27             input.close();
28         }
29  
30     }
31  
32     private void sendFile(HttpServletRequest request,
33       HttpServletResponse response, BufferedInputStream input)
34       throws IOException, InterruptedException {
35         byte[] buffer = new byte[TokenBucket.TOKEN_PERMIT_SIZE];
36         final ServletOutputStream outputStream = response.getOutputStream();
37         for (int count = input.read(buffer); count > 0; count = input.read(buffer)) {
38             tokenBucket.takeBlocking();
39             outputStream.write(buffer, 0, count);
40         }
41     }
42 }

HttpRequestHandlerServlet was used here. As simple as can be: read 20 kiB of file, take the token from the bucket (waiting if unavailable), send chunk to the client, repeat until the end of file.

Believe it or not, this actually works! No matter how many (or how few) clients are concurrently accessing this servlet, total outgoing network bandwidth never exceeds 20 MiB! The algorithm works and I hope you get some basic feeling how to use it. But let’s face it – global limit is way too inflexible and kind of lame – single client can actually consume your whole bandwidth.

So what if we had a separate bucket for each client? Instead of one semaphore – a map? Each client has a separate independent bandwidth limit, so there is no risk of starvation. But there is even more:

some clients might be more privileged, having bigger or no limit at all,
some might be black listed, resulting in connection rejection or very low throughput
banning IPs, requiring authentication, cookie/user agent verification, etc.
we might try to correlate concurrent requests coming from the same client and use the same bucket for all of them to avoid cheating by opening several connections. We might also reject subsequent connections
and much more…

Our bucket interface grows allowing the implementation to take advantage of the new possibilities (see branch per-request-synch):

01 public interface TokenBucket {
02  
03     void takeBlocking(ServletRequest req) throws InterruptedException;
04     void takeBlocking(ServletRequest req, int howMany) throws InterruptedException;
05  
06     boolean tryTake(ServletRequest req);
07     boolean tryTake(ServletRequest req, int howMany);
08  
09     void completed(ServletRequest req);
10 }
11  
12  
13 public class PerRequestTokenBucket extends TokenBucketSupport {
14  
15     private final ConcurrentMap<Long, Semaphore> bucketSizeByRequestNo = newConcurrentHashMap<Long, Semaphore>();
16  
17     @Override
18     public void takeBlocking(ServletRequest req, int howMany) throws InterruptedException {
19         getCount(req).acquire(howMany);
20     }
21  
22     @Override
23     public boolean tryTake(ServletRequest req, int howMany) {
24         return getCount(req).tryAcquire(howMany);
25     }
26  
27     @Override
28     public void completed(ServletRequest req) {
29         bucketSizeByRequestNo.remove(getRequestNo(req));
30     }
31  
32     private Semaphore getCount(ServletRequest req) {
33         final Semaphore semaphore = bucketSizeByRequestNo.get(getRequestNo(req));
34         if (semaphore == null) {
35             final Semaphore newSemaphore = new Semaphore(0false);
36             bucketSizeByRequestNo.putIfAbsent(getRequestNo(req), newSemaphore);
37             return newSemaphore;
38         else {
39             return semaphore;
40         }
41     }
42  
43     private Long getRequestNo(ServletRequest req) {
44         final Long reqNo = (Long) req.getAttribute(REQUEST_NO);
45         if (reqNo == null) {
46             throw new IllegalAccessError("Request # not found in: " + req);
47         }
48         return reqNo;
49     }
50  
51 }

The implementation is very similar (full class here) except that the single semaphore was replaced by map. I am not using request object itself as a map key for various reasons but a unique request number that I am assigning manually when receiving new connection. Calling completed() is very important, otherwise the map would grow continuously leading to memory leak. All in all, the token bucket implementation haven’t changed a lot, also the download servlet is almost the same (except passing request to token bucket). We are now ready for some stress testing!

Throughput Testing

For the testing purposes we will use JMeter with this wonderful set of plugins. During the 20-minute testing session we warm up our server firing up one new thread (concurrent connection) every 6 seconds to reach 100 threads after 10 minutes. For the next ten minutes we will keep 100 concurrent connections to see how stable the server works. Here are the active threads over time:

Important note: I artificially lowered the number of HTTP worker threads to 10 in Tomcat (7.0.10 tested). This is a far from real configuration, but I wanted to emphasize some phenomena that occur with high load compared to server capabilities. With default pool size I would need several client machines running distributed JMeter session to generate enough traffic. If you have a server farm or couple of servers in the cloud (as opposed to my 3-year-old laptop), I would be delighted to see the results in more realistic environment.

Remembering how many HTTP worker threads are available in Tomcat, response times over time are far from satisfactory:

Please note the plateau at the beginning of the test: after about a minute (hint: when the number of concurrent connections exceeds 10) response times are skyrocketing to stabilize at around 10 seconds after 10 minutes (number of concurrent connections reaches one hundred). Once again: the same behavior would occur with 100 worker threads and 1000 concurrent connections – it’s just a matter of scale. The response latencies graph (time between sending request and receiving first lines of response) clears any doubts:

Below the magical number of 10 threads our application responds almost instantly. This is really important for clients as receiving only headers (especially Content-Type and Content-Length) allows them to more accurately inform the user what is going on. So what is the reason of Tomcat waiting with the response? No magic here really. We have only 10 threads and each connection requires one thread, so Tomcat (and any other pre-Servlet 3.0 container) handles 10 clients while the remaining 90 are… queued. The moment one of the 10 lucky ones is done, one connection from the queue is taken. This explains average 9 second latency whilst the servlet needs only 1 second to serve the request (200 kiB with 20 kiB/s limit). If you are still not convinced, Tomcat provides nice JMX indicators showing how many threads are occupied and how many requests are queued:

With traditional servlets there is nothing we can do. Throughput is horrible but increasing the total number of threads is not an option (think: from 100 to 1000). But you don’t actually need a profiler to discover that threads aren’t the true bottleneck here. Look carefully at DownloadServletHandler, where do you think most of the time is spent? Reading a file? Sending data back to the client? No, the servlet waits… And then waits even more. Non-productively hanging on semaphore – thankfully CPU is not harmed, but what if it was implemented using busy waiting? Luckily Tomcat 7 finally supports…

Servlet 3.0 asynchronous processing

We are this close to increase our server capacity by an order of magnitude, but some non-trivial changes are required (see masterbranch). First, download servlet needs to be marked as asynchronous (OK, this is still trivial):

1 @WebServlet(urlPatterns = "/*", name="downloadServletHandler", asyncSupported = true)
2 public class DownloadServlet extends HttpRequestHandlerServlet {}

The main change occurs in download handler. Instead of sending the whole file in a loop with lots of waiting (takeBlocking()) involved, we are splitting the loop into separate iterations, each wrapped inside Callable. Now we will utilize a small thread pool that will be shared by all awaiting connections. Each task in the pool is very simple: instead of waiting for a token, it asks for it in a non-blocking fashion (tryTake()). If the token is available, piece of the file is sent to the client (sendChunkWorthOneToken()). If the token is not available (bucket is empty), nothing happens. No matter whether the token was available or not, the task resubmits itself to the queue for further processing (this is essentially very fancy, multi-threaded loop). Because there is only one pool, the task lands at the end of the queue allowing other connections to be served.

01 @Service
02 public class DownloadServletHandler implements HttpRequestHandler {
03  
04     @Resource
05     private TokenBucket tokenBucket;
06  
07     @Resource
08     private ThreadPoolTaskExecutor downloadWorkersPool;
09  
10     @Override
11     public void handleRequest(HttpServletRequest request, HttpServletResponse response) throwsServletException, IOException {
12         final File file = new File("/home/dev/tmp/ehcache-1.6.2.jar");
13         response.setContentLength((int) file.length());
14         final BufferedInputStream input = new BufferedInputStream(new FileInputStream(file));
15         final AsyncContext asyncContext = request.startAsync(request, response);
16         downloadWorkersPool.submit(new DownloadChunkTask(asyncContext, input));
17     }
18  
19     private class DownloadChunkTask implements Callable<Void> {
20  
21         private final BufferedInputStream fileInputStream;
22         private final byte[] buffer = new byte[TokenBucket.TOKEN_PERMIT_SIZE];
23         private final AsyncContext ctx;
24  
25         public DownloadChunkTask(AsyncContext ctx, BufferedInputStream fileInputStream) throwsIOException {
26             this.ctx = ctx;
27             this.fileInputStream = fileInputStream;
28         }
29  
30         @Override
31         public Void call() throws Exception {
32             try {
33                 if (tokenBucket.tryTake(ctx.getRequest())) {
34                     sendChunkWorthOneToken();
35                 else
36                     downloadWorkersPool.submit(this);
37             catch (Exception e) {
38                 log.error("", e);
39                 done();
40             }
41             return null;
42         }
43  
44         private void sendChunkWorthOneToken() throws IOException {
45             final int bytesCount = fileInputStream.read(buffer);
46             ctx.getResponse().getOutputStream().write(buffer, 0, bytesCount);
47             if (bytesCount < buffer.length)
48                 done();
49             else
50                 downloadWorkersPool.submit(this);
51         }
52  
53         private void done() throws IOException {
54             fileInputStream.close();
55             tokenBucket.completed(ctx.getRequest());
56             ctx.complete();
57         }
58     }
59  
60 }

I am leaving the details of Servlet 3.0 API, there are plenty of less sophisticated examples throughout the Internet. Just remember to call startAsync() and work with returned AsyncContext instead of plain request and response.

BTW creating a thread pool using Spring is childishly easy (and we get nice thread names as opposed to Executors andExecutorService):

That’s right, one thread is enough to serve one hundred concurrent clients. See for yourself (the amount of HTTP worker threads is still 10 and yes, the scale is in milliseconds).

Response Times over Time

Response Latencies over Time

As you can see, response times when one hundred clients are downloading a file concurrently are only about 5% higher compared to the system with almost no load. Also response latencies aren’t particularly harmed by increasing load. I can’t push the server even further due to my limited hardware resources, but I have reasons to believe that this simple application would handle even twice as more connection: both HTTP threads and download worker thread weren’t fully utilized during the whole test. This also means that we have increased our server capacity 10 times without even using all the threads!

Hope you enjoyed this article. Of course not every use case can be scaled so easily, but next time you’ll notice your servlet is mainly waiting – don’t waste HTTP threads and consider servlet 3.0 asynchronous processing. And test, measure and compare! The complete application source codes are available (look at different branches), including JMeter test plan.

Areas of improvement

There are still several places that require attention and improvement. If you want to, don’t hesitate, fork, modify and test:

  • While profiling I discovered that in more than 80% of executions DownloadChunkTask does not acquire a token and only reschedules itself. This is an awful waste of CPU time that can be fixed quite easily (how?)
  • Consider opening a file and sending content length in a worker thread rather than in an HTTP thread (before starting asynchronous context)
  • How can one implement global limit on top of bandwidth limits per request? You have at least couple of choice: either limit the size of download workers pool queue and reject executions or wrap PerRequestTokenBucket with reimplemented GlobalTokenBucket (decorator pattern)
  • TokenBucket.tryTake() method does clearly violate Command-query separation principle. Could you suggest how it should look like to follow it? Why it is so hard?
  • I am aware that my test constantly reads the same small file, so the I/O performance impact is minimal. But in real life scenario some caching layer would have certainly be applied on top of disk storage. So the difference is not that big (now the application uses very small amount of memory, lots of place for cache).

Lessons Learned

  • Loopback interface is not infinitely fast. In fact on my machine localhost was incapable of processing more than 80 MiB/s.
  • I don’t use plain request object as a key in bucketSizeByRequestNo. First of all, there are no guarantees on equals() and hashCode() for this interface. And more importantly – read the next point…
  • With servlets 3.0 when processing the request you have to call completed() explicitly to flush and close the connection. Obviously after calling this method request and response objects are useless. What wasn’t obvious (and I learned that the hard why) is that Tomcat reuses request objects (pooling) and some of their contents for subsequent connections. This means that the following code is incorrect and dangerous, possibly resulting in accessing/corrupting other requests’ attributes or even session (?!?)
1 ctx.complete();
2 ctx.getRequest().getAttribute("SOME_KEY");

That’s it. A very nice tutorial on increasing a server’s throughput by using Servlet 3.0 async processing by Tomasz Nurkiewicz, one of ourJCG partners. Don’t forget to share!

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics