Table of Contents
- Introduction
- Demo time!
- Stage-0
- Stage-1
- Stage-2
- Why is Stage-2 slow and Stage-1 fast with the same code?
- Monkey patching and re-patching.
- Profiling Stage-1 and Stage-2 code.
- Should I just uninstall pyopenssl and be fast again?
- Summary
Introduction
This post describes an interesting issue I ran into while using Python’s grequests module.
If you have to download n pages of a site with the format https://site?page=n
where n = 1 to 100 in python, the following snippet of code would be very slow as it would
download them one by one.
An obvious solution in the Python world is to use the grequests module, wherein one would write code similar to the following to get speed gains:
The above code runs faster because:
where
At a very high level something like this happens:
When you use requests without gevent:
- Client opens a connection to the server
- The client sends the request.
- Client waits for server to respond.
- Server responds.
- Client goes back to Point 1. for next URL.
When you use grequests:
- Client opens a connection to the server
- The client sends the request.
- The client does not wait for the server to respond (an IO wait), it returns control back to an event loop by registering a callback which is fired when the server responds.
- Client does Point 1-3 for rest of the URLs.
How does grequests accomplish this?
- grequests imports gevent and uses gevent to monkey patch the standard lib.
- Now the underlying
recv
call is made non-blocking by gevent’s library.
What happens during monkey patching?
- Run the following code in your Python2.7 interpreter console:
- Close and re-open your Python2.7 interpreter console and run similar code but after monkey patching through gevent: As you noticed, monkey.patch_all ensures that if we reference any modules that gevent patches, we get their gevent variants instead of the vanilla ones.
To understand gevent in more detail go through:
Given this is how gevent works, using grequests to fetch 100 urls should do the trick. But as we will see in the later sections, that depends on the Python modules installed on your system.
Demo time!
Clone this repo which has the sample setup. Run this docker-compose command to get the test environment up and running:
This will create the following containers on your system:
- https_server_1 - A local golang https server which mimics the very useful httpbin.org, which provides the /delay endpoint to introduce latency in response.
- python27_n where n = 1 to 4 - Python2.7 containers with different pip modules installed which show the situations in which the code is fast or slow.
- python37_n where n = 1 to 4 - Same as above but for Python3.7
We will go through these stages and see grequests
behaviour.
Each stage is going to differ from the previous in the list of modules installed on the docker container. The code that we run in these stages is going to stay the same.
Stage-0
-
Repo path: here
-
Modules installed: Not applicable as we are not using virtualenv.
-
Verdict: Python2.7 may run slow and Python3.7 may run fast.
- Experiment output
- Reasoning: As per the disclaimer, because this stage doesn’t use virtualenv, the run time will depend on the Python modules you have installed on your system. We will uncover this behaviour in more depth in the coming stages.
Stage-1
-
Repo path: here
- Modules installed (same for both):
- Experiment output
Stage-2
-
Repo path: here
- Modules installed (same for both):
- Experiment output
Why is Stage-2 slow and Stage-1 fast with the same code?
- We will show the reasoning for Python2.7 as it applies to Python3.7 also.
- The difference between stage-2 and stage-1 is that Stage-2 has the pyopenssl module installed. (compare the Modules Installed in above stages)
- Using pyopenssl does something which leads to the underlying socket class to change e.g. Stage-1 Python2.7 socket class v/s Stage-2 Python2.7 socket class
- Stage-1 Python2.7 socket class = gevent._sslgte279.SSLSocket and Stage-2 Python2.7 socket class = urllib3.contrib.pyopenssl.WrappedSocket.
- grequests internally uses requests which uses urllib3
- requests module, during initialization tries to include pyopenssl optionally as per here.
- If the user has pyopenssl installed, do
pyopenssl.inject_into_urrllib3
. If the user doesn’t have it installed, this call fails which is caught byImportError
which doespass
, which made the Stage-1ImportError
silent. - Using pyopenssl was introduced in this commit to take advantage of urllib3’s SNI support.
- The side effect of using pyopenssl is that it re-patches the already monkey-patched SSLContext which denies us any gevent magic. SSLContext is used to add SSL capabilities to a socket.
- Let us see how this re-patching happened and why it led to slower code in the next 2 sections.
Monkey patching and re-patching.
Let’s use the already existing Stage-1 and Stage-2 Python interpreter console’s to clear this out. Keep in mind that Stage-2 had pyopenssl installed, while Stage-1 didn’t. We will demo with Python2.7 as the same output applies to Python3.7 also.
- Stage-1 Python2.7:
- Get the interpreter console:
- Import urllib3 and print the SSLContext:
- Close and re-open the interpreter console and import urllib3 after monkey patching and then print the SSLContext:
- Close and re-open the interpreter console and import urllib3 after importing grequests then print the SSLContext:
- Stage-2 Python2.7:
- Get the interpreter console:
- Import urllib3 and print the SSLContext:
- Close and re-open the interpreter console and import urllib3 after monkey patching and then print the SSLContext:
- Close and re-open the interpreter console and import urllib3 after importing grequests then print the SSLContext:
Profiling Stage-1 and Stage-2 code.
If you observe the output for Stage-2 you will see that there is a call to wait:
The source of /usr/local/lib/python2.7/site-packages/urllib3/util/wait.py:99(do_poll)
:
We see that there is an explicit wait of 1 second (1000 ms) happening on when the socket is blocked. One more interesting observation is that for Stage-2 the recv socket call is made from pyopenssl.py. which leads to polled waiting:
For Stage-1 the recv socket call is made from gevent which doesn’t get blocked on IO and moves on to processing the next request:
Hence there is no polled wait.
Should I just uninstall pyopenssl and be fast again?
You can do that and get the speed gains. But as per this:
In my case, I wanted to do HTTPS verification (verify=True flag in requests). The latest ssl library has these features.
But if you are using Python older than 2.7.9, then you have to use pyopenssl for the above features:
The following commands are run on Python 2.7.8 interpreter without pyopenssl:
If we install pyopenssl and retry verification, it goes through:
But what if you need to use other features of pyopenssl related to certificate management which may or may not be in the core ssl library? gevent-openssl has got you covered. It is a gevent wrapper over pyopenssl so that you can use pyopenssl and prevent the re-patching scenario that we ran into.
However, installing gevent-openssl solves the problem of running fast with pyopenssl on Python2.7 but it doesn’t do so for Python3.7. We will learn more about the same in my next post as we explore Stage-3 and Stage-4.
Summary
- Stage-0 is unpredictable because we are not using virtualenv.
- Stage-1 verifies that the absence of pyopenssl makes both Python 2.7 and Python 3.7 fast.
- Stage-2 verifies that the presence of pyopenssl makes both Python 2.7 and Python 3.7 slow.