mirror of
https://github.com/python-telegram-bot/python-telegram-bot.git
synced 2024-11-24 16:17:37 +01:00
Updated Performance Optimizations (markdown)
parent
a43c63b57a
commit
b26f16aeea
1 changed files with 26 additions and 26 deletions
|
@ -1,25 +1,25 @@
|
|||
## Introduction
|
||||
When your bot becomes popular, you will eventually want to improve response times. After all, Telegram places high priority on fast messaging. At the same time, responses become slower as more people are using your bot at the same time. This happens more quickly for inline bots, as they may receive multiple inline queries during one interaction.
|
||||
When your bot becomes popular, you will eventually want to improve response times. After all, Telegram places high priority on fast messaging. At the same time, responses become slower as more people are using your bot. This happens more quickly for inline bots, as they may receive multiple inline queries during one interaction.
|
||||
|
||||
There are of course many ways to tackle this problem. I'll talk about the two things that, in my experience, get you the biggest "bang for the buck". The first is using threads to *handle requests asynchronously*, the second is *choosing a good server location.*
|
||||
There are of course many ways to tackle this problem. I'll talk about two that, in my experience, get you the biggest "bang for the buck". The first is using threads to *handle requests asynchronously*, the second is *choosing a good server location.*
|
||||
|
||||
### Threading
|
||||
### Multithreading
|
||||
|
||||
#### How does it work?
|
||||
The first thing you should know is that the `telegram.ext` submodule uses multi-threading for the different tasks it carries out. `Updater`, `Dispatcher` and `JobQueue` each run in their own thread, separate from the main thread. This is mostly hidden from you, but not completely. For example, the `Updater.start_polling` and `start_webhook` methods are non-blocking, meaning that the execution of your script resumes after calling them (that's why you have to call `Updater.idle` btw).
|
||||
The first thing you should know is that the `telegram.ext` submodule uses multithreading for the different tasks it carries out. `Updater`, `Dispatcher` and `JobQueue` each run in their own thread, separate from the main thread. This is mostly hidden from you, but not completely. For example, the `Updater.start_polling` and `start_webhook` methods are non-blocking, meaning that the execution of your script resumes after calling them (that's why you have to call `Updater.idle`).
|
||||
|
||||
**Note:** This library uses the `threading` module for all concurrency. Because of the [Global Interpreter Lock](https://wiki.python.org/moin/GlobalInterpreterLock) (you don't need to know what that is), **this does not actually make your code run faster**. The real advantage is that I/O operations like network communication (eg. sending a message to a user) or reading/writing on your hard drive can run in parallel. These usually take very long, compared to the rest of your code (I'm talking >95% here), and especially with networking there's a lot of waiting involved.
|
||||
**Note:** This library uses the [`threading`](https://docs.python.org/3/library/threading.html) module for all concurrency. Because of the [Global Interpreter Lock](https://wiki.python.org/moin/GlobalInterpreterLock) (you don't need to know what that is), **this does not actually make your code run faster**. The real advantage is that I/O operations like network communication (eg. sending a message to a user) or reading/writing on your hard drive can run concurrently. These usually take very long, compared to the rest of your code (I'm talking >95% here), and especially with networking there's a lot of waiting involved.
|
||||
|
||||
Still, when it comes to handling individual requests, no multi-threading is used **by default**. All handler callback functions you register in the Dispatcher are executed in the `dispatcher` thread, one after another. So, if one callback function takes some time to execute, all other requests have to wait for it.
|
||||
Still, when it comes to handling individual requests, no multithreading is used **by default**. All handler callback functions you register in the Dispatcher are executed in the `dispatcher` thread, one after another. So, if one callback function takes some time to execute, all other requests have to wait for it.
|
||||
|
||||
**Example:** You're running the [Echobot](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot2.py) and two users (*User A* and *User B*) send a message to the bot at the same time. Maybe *User A* was a bit quicker, so his request arrives first, in the form of an `Update` object (*Update A*). The Dispatcher checks the Update and decides it should be handled by the handler with the callback function named `echo`. At the same time, the `Update` of *User B* arrives (*Update B*). But the Dispatcher is not finished with *Update A*. It calls the `echo` function with *Update A*, which sends a reply to *User A*. Sending a reply takes some time (see [Server location](#server-location)), and *Update B* remains untouched during that time. Only after the `echo` function finishes for *Update A*, the Dispatcher repeats the same process for *Update B*.
|
||||
|
||||
So, how do you get around that? Note that I said **by default**. To solve this kind of problem, the library provides a way to explicitly run a callback function (or any other function) in a separate thread. Before I show you how that looks, let's see how that affects the situation in our example. After you read this article, you marked the `echo` callback function to run in its own thread. Now, when the Dispatcher determined that the `echo` function should handle *Update A*, it creates a new thread with it as the target and *Update A* as an argument and starts the thread. Immediately after starting the thread, it repeats the process for *Update B* without any further delay. Both replies are sent **concurrently**.
|
||||
|
||||
#### How to use it
|
||||
I don't want to bore you with *knowledge* any further, so let's see some code! Sticking with the Echobot example, this is how you can mark the `echo` function to run in a thread:
|
||||
I don't want to bore you with *words* any further, so let's see some code! Sticking with the Echobot example, this is how you can mark the `echo` function to run in a thread:
|
||||
|
||||
At the beginning of your script, import `run_async` like this:
|
||||
At the beginning of your script, import `run_async`:
|
||||
|
||||
```python
|
||||
from telegram.ext.dispatcher import run_async
|
||||
|
@ -33,10 +33,10 @@ def echo(bot, update):
|
|||
bot.sendMessage(update.message.chat_id, text=update.message.text)
|
||||
```
|
||||
|
||||
This seems quite simple and straightforward, right? So, why did I bore you with all that stuff before?
|
||||
Simple and straightforward, right? So, why did I bore you with all that stuff before?
|
||||
|
||||
#### Things to look out for
|
||||
Sadly, programming with threads rarely is simple and straightforward. There's a lot of traps to fall into, and I'll try to give you a few hints on how to spot them. However, this wiki article does not replace ~~the advice of a doctor~~ a university lecture on concurrency.
|
||||
#### Common Pitfalls
|
||||
Sadly, programming with threads is rarely simple. There are lots of traps to fall into, and I'll try to give you a few hints on how to spot them. However, this wiki article does not replace ~~your psychiatrist~~ a university lecture on concurrency.
|
||||
|
||||
##### Shared state
|
||||
This is probably the biggest cause of issues with threading, and those issues are hard to fix. So instead of showing you how to fix them, I'll show you how to avoid them altogether. More about that later.
|
||||
|
@ -73,29 +73,29 @@ def transaction(bot, update):
|
|||
I skipped some of the implementation details, so here's a short explanation:
|
||||
|
||||
- `parse_update` extracts the user id's of the sender (`source_id`) and receiver (`target_id`) from the message
|
||||
- `bank` is a globally accessable object that exposes the Python API of the banks operations
|
||||
- `bank` is a globally accessible object that exposes the Python API of the banks operations
|
||||
- `bank.read_account` reads a bank account from the bank's database into a Python object
|
||||
- `bank.read_account` writes a bank account back to the bank's database
|
||||
- `bank.log` must be used to keep a log of all changes to make sure no money is lost
|
||||
|
||||
Sadly, ~~you~~ that damn intern fell right into the trap. Let's say there are two morally corrupt customers, *Customer A* with *Account A* and *Customer B* with *Account B*, who both make a transaction simultaneously. *Customer A* sends *Transaction AB* of *10CU* to *Customer B*. At the same time, *Customer B* sends a *Transaction BA* of *100CU* to *Customer A*. CU simply means **C**urrency **U**nit like Dollar or Euro.
|
||||
Sadly, ~~you~~ that damn intern fell right into the trap. Let's say there are two morally corrupt customers, *Customer A* with *Account A* and *Customer B* with *Account B*, who both make a transaction simultaneously. *Customer A* sends *Transaction AB* of *10CU* to *Customer B*. At the same time, *Customer B* sends a *Transaction BA* of *100$* to *Customer A*.
|
||||
|
||||
Now the Dispatcher starts two threads, *Thread AB* and *Thread BA*, almost simultaneously. Both threads read the accounts from the database with the **same** balance and calculate a new balance for both of them. In most cases, one of the two transactions will simply overwrite the other. That's not too bad, but will at least be confusing to the customers. But threads are quite unpredictable and can be [suspended and resumed by the operating system](https://en.wikipedia.org/wiki/Scheduling_(computing)) at *any* point in the code, so the following order of execution can occur:
|
||||
|
||||
1. *Thread AB* executes `bank.write_account(source)` and updates *Account A* with *-10CU*
|
||||
1. *Thread AB* executes `bank.write_account(source)` and updates *Account A* with *-10$*
|
||||
2. Before updating *Account B*, *Thread AB* is put to sleep by the operating system
|
||||
3. *Thread BA* is resumed by the operating system
|
||||
4. *Thread BA* executes `bank.write_account(source)` and updates *Account B* with *-100CU*
|
||||
5. *Thread BA* also executes `bank.write_account(target)` and updates *Account A* with *+100CU*
|
||||
6. When *Thread AB* is resumed again, it executes `bank.write_account(target)` and updates *Account B* with *+10CU*
|
||||
4. *Thread BA* executes `bank.write_account(source)` and updates *Account B* with *-100$*
|
||||
5. *Thread BA* also executes `bank.write_account(target)` and updates *Account A* with *+100$*
|
||||
6. When *Thread AB* is resumed again, it executes `bank.write_account(target)` and updates *Account B* with *+10$*
|
||||
|
||||
In the end, *Account A* is at *+100CU* and *Account B* is at *+10CU*. Of course, this won't happen very often. And that's what makes this bug so critical. It will probably be missed by your tests and end up in production, potentially causing a lot of financial damage.
|
||||
In the end, *Account A* is at *+100$* and *Account B* is at *+10CU*. Of course, this won't happen very often. And that's what makes this bug so critical. It will probably be missed by your tests and end up in production, potentially causing a lot of financial damage.
|
||||
|
||||
**Note:** This kind of bug is called [race condition](https://en.wikipedia.org/wiki/Race_condition) and has been the source of many, many security vulnerabilities. It's also one of the reasons why banking software is not written by unpaid interns.
|
||||
**Note:** This kind of bug is called a [race condition](https://en.wikipedia.org/wiki/Race_condition) and has been the source of many, many security vulnerabilities. It's also one of the reasons why banking software is not written by unpaid interns.
|
||||
|
||||
To be fair, you probably don't write software for banks (if you do, you should already know about this), but this kind of bug can occur in any piece of code that shares *state* across threads. While in this case, the shared state is the `bank` object, it can take many forms. A database, a `dict`, a `list` or any other kind of object that is modified by more than one thread. Depending on the situation, race conditions are more or less likely to occur, and the damage they do is bigger or smaller, but as a rule of thumb, they're bad.
|
||||
|
||||
There are many ways to fix race conditions in a multi-threaded environment, but I won't explain any of them here. Mostly because it probably isn't worth the work; partly because it's cumbersome and I feel lazy. Instead, as promised in the first paragraph, I'll show you how to avoid them completely. That's not always as easy as it is in this case, but we're lucky:
|
||||
There are many ways to fix race conditions in a multithreaded environment, but I won't explain any of them here. Mostly because it probably isn't worth the work; partly because it's cumbersome and I feel lazy. Instead, as promised in the first paragraph, I'll show you how to avoid them completely. That's not always as easy as it is in this case, but we're lucky:
|
||||
|
||||
1. Our set of tools is very limited - `@run_async` is the only thread-related tool we're using
|
||||
2. Our goals are not very ambitious - we only want to speed up our I/O
|
||||
|
@ -106,7 +106,7 @@ There are two relatively simple steps you have to follow. First, identify those
|
|||
2. *Reads* shared state and *relies on* it being correct
|
||||
3. *Modifies* local state (eg. a variable used later in the same function)
|
||||
|
||||
Make sure you have a good idea what *shared state* means. Don't hesitate to do a quick google search on it.
|
||||
Make sure you have a good idea what *shared state* means. Don't hesitate to do a quick Google search on it.
|
||||
|
||||
I went through our bank example line by line and noted which of the criteria it matches, here's the result:
|
||||
|
||||
|
@ -135,7 +135,7 @@ def transaction(bot, update):
|
|||
bank.log(FINISHED_TRANSACTION, amount, source_id, target_id) # None
|
||||
```
|
||||
|
||||
**Note:** One could argue that `bank.log` modifies shared state. However, logging libraries are usually implemented thread-safe and it's unlikely that the log has a critical functional role. It's not being read from in this function, and I assume it's not being read from anywhere else in the code, so maybe consider this an exception to the rule. Also, for the sake of this example, it'd be boring if only `bot.sendMessage` would be safe to run in parallel. However, we will keep this in mind for the next step.
|
||||
**Note:** One could argue that `bank.log` modifies shared state. However, logging libraries are usually thread-safe and it's unlikely that the log has a critical functional role. It's not being read from in this function, and I assume it's not being read from anywhere else in the code, so maybe consider this an exception to the rule. Also, for the sake of this example, it'd be boring if only `bot.sendMessage` would be safe to run in parallel. However, we will keep this in mind for the next step.
|
||||
|
||||
As you can see, there's a pretty obvious pattern here: `bot.sendMessage` and `bank.log` are not matching any criteria we have set for strictly sequential code. That means we can run this code asynchronously without risk. Therefore, the second step is to extract that code to separate functions and mark them with `@run_async`. Since our async code parts are all very similar, they can be replaced by a single function. We could have done that before, but then this moment would've been less cool.
|
||||
|
||||
|
@ -173,9 +173,9 @@ def transaction(bot, update):
|
|||
|
||||
By separating the strictly sequential code from the asynchronous code, we made sure that no race conditions can occur. The `transaction` function won't be executed concurrently anymore, but we still managed to gain some substantial performance boost over completely sequential code, because the logging and user notification is now run in parallel.
|
||||
|
||||
At this point, let me say: **Congratulations!** :tada: and thank you for reading :grin: If you got this far without giving up, please consider a CompSci-related major at university, if you have that opportunity. If I left you with a question or two, post a message in our [Telegram Group](https://telegram.me/pythontelegrambotgroup) and mention @jh0ker. If you found this easy to grasp and/or are eager to learn more about all that threading stuff, consider reading the [documentation of the threading module](https://docs.python.org/3/library/threading.html) or learn about [asyncio](https://docs.python.org/3/library/asyncio.html), a modern and arguably better approach to asynchronous I/O that does not use multi-threading.
|
||||
At this point, let me say: **Congratulations!** :tada: and thank you for reading :grin: If you got this far without giving up, please consider a CompSci-related major at university, if you have that opportunity. If I left you with a question or two, post a message in our [Telegram Group](https://telegram.me/pythontelegrambotgroup) and mention @jh0ker. If you found this easy to grasp and/or are eager to learn more about all that threading stuff, consider reading the [documentation of the threading module](https://docs.python.org/3/library/threading.html) or learn about [asyncio](https://docs.python.org/3/library/asyncio.html), a modern and arguably better approach to asynchronous I/O that does not use multithreading.
|
||||
|
||||
As you may have learned after all this, writing good, thread-safe code is no exact science. A few last helpful guidelines for threaded code:
|
||||
As you may now have learned, writing good, thread-safe code is no exact science. A few last helpful guidelines for threaded code:
|
||||
|
||||
- Avoid using shared state whenever possible
|
||||
- Write self-contained ([pure](https://en.wikipedia.org/wiki/Pure_function)) functions
|
||||
|
@ -183,7 +183,7 @@ As you may have learned after all this, writing good, thread-safe code is no exa
|
|||
- Asynchronous functions can't return values (at least not in this implementation)
|
||||
|
||||
##### Limits
|
||||
The maximum of concurrent threads is limited. This limit is 4 by default. To increase this limit, you can pass the keyword argument `workers` to the `Updater` initialization, like this:
|
||||
The maximum of concurrent threads is limited. This limit is 4 by default. To increase this limit, you can pass the keyword argument `workers` to the `Updater` initialization:
|
||||
|
||||
```python
|
||||
updater = Updater(TOKEN, workers=32)
|
||||
|
@ -208,7 +208,7 @@ def parent():
|
|||
child()
|
||||
```
|
||||
|
||||
If you limited the maximum amount of threads to 2 and call the `parent` function, you start a thread. This thread calls the `child` function and starts another thread, so the amount of concurrent threads has reached 2. It now tries to call the `child` function a second time, but has to wait until the just started `child` thread ended. The `child` thread tries to call `grandchild`, but it has to wait until the `parent` thread ended. Now both threads are waiting for each other and blocking all other code that tries to run an asynchronous function. The calling thread (usually the Dispatcher) is effectively dead, hence the term *deadlock*.
|
||||
If you limited the maximum amount of threads to 2 and call the `parent` function, you start a thread. This thread calls the `child` function and starts another thread, so the amount of concurrent threads is 2. It now tries to call the `child` function a second time, but has to wait until the just started `child` thread ended. The `child` thread tries to call `grandchild`, but it has to wait until the `parent` thread ended. Now both threads are waiting for each other and blocking all other code that tries to run an asynchronous function. The calling thread (usually the Dispatcher) is effectively dead, hence the term *deadlock*.
|
||||
|
||||
### Server location
|
||||
All that multi-threading will only get you *so far*. Another potential bottleneck is the time your server (the computer that runs your bot script) needs to contact the Telegram server.
|
||||
|
|
Loading…
Reference in a new issue