Melhorar o desempenho da taxa de transferência de aplicativos Python no Azure Functions

Ao desenvolver para Azure Functions usando o Python, é necessário entender o desempenho de suas funções e como esse desempenho afeta a maneira como seu aplicativo de funções é dimensionado. A necessidade é mais importante ao criar aplicativos com alto desempenho. Os principais fatores a serem considerados ao criar, gravar e configurar aplicativos de funções são as configurações de escala horizontal e desempenho da taxa de transferência.

Dimensionamento horizontal

Por padrão, o Azure Functions monitora automaticamente a carga em seu aplicativo e cria mais instâncias de host para Python conforme a necessidade. O Azure Functions usa limites internos em tipos de gatilhos diferentes para decidir quando adicionar instâncias, como por exemplo, a idade das mensagens e o tamanho da fila para QueueTrigger. Esses limites não são configuráveis pelo usuário. Para obter mais informações, consulte Dimensionamento controlado por eventos no Azure Functions.

Aprimorar o desempenho da taxa de transferência

As configurações padrão são adequadas para a maioria dos aplicativos Azure Functions. No entanto, é possível melhorar o desempenho da taxa de transferência de seus aplicativos empregando configurações com base no perfil de sua carga de trabalho. A primeira etapa é entender o tipo de carga de trabalho que você está executando.

Tipo de carga de trabalho Características do aplicativo de funções Exemplos
Limite de E/S • O aplicativo precisa lidar com muitas invocações simultâneas.
• O aplicativo processa um grande número de eventos de E/S, como chamadas de rede e leitura/gravação em disco.
• APIs da Web
Limite de CPU • O aplicativo faz cálculos de execução longa, como o redimensionamento de imagem.
• O aplicativo faz transformação de dados.
• Processamento de dados
• Inferência de Machine Learning

Como as cargas de trabalho de função do mundo real geralmente são uma combinação de E/S e limite de CPU, você deve criar o perfil do aplicativo em cargas de produção realistas.

Configurações específicas de desempenho

Após entender o perfil de carga de trabalho de seu aplicativo de funções, as seguintes são configurações que você pode usar para melhorar o desempenho da taxa de transferência de suas funções.

Async

Como o Python é um runtime de thread único, uma instância de host para Python pode processar apenas uma invocação de função por vez, por padrão. Para aplicativos que processam um grande número de eventos de E/S e/ou têm limite de E/S, é possível melhorar o desempenho de forma significativa executando as funções de forma assíncrona.

Para executar uma função de forma assíncrona, use a instrução async def, que executa a função com async def diretamente:

async def main():
    await some_nonblocking_socket_io_op()

Aqui está um exemplo de uma função com um gatilho HTTP que usa o cliente http aiohttp:

import aiohttp

import azure.functions as func

async def main(req: func.HttpRequest) -> func.HttpResponse:
    async with aiohttp.ClientSession() as client:
        async with client.get("PUT_YOUR_URL_HERE") as response:
            return func.HttpResponse(await response.text())

    return func.HttpResponse(body='NotFound', status_code=404)

Uma função sem a palavra-chave async é executada automaticamente em um pool de threads ThreadPoolExecutor:

# Runs in a ThreadPoolExecutor threadpool. Number of threads is defined by PYTHON_THREADPOOL_THREAD_COUNT. 
# The example is intended to show how default synchronous functions are handled.

def main():
    some_blocking_socket_io()

Para obter o benefício total da execução de funções de forma assíncrona, a operação/biblioteca de E/S usada em seu código também precisa ter a implementação assíncrona. O uso de operações de E/S síncronas em funções que são definidas como assíncronas pode prejudicar o desempenho geral. Se as bibliotecas que você está usando não tiverem a versão assíncrona implementada, ainda é possível se beneficiar com a execução de seu código de forma assíncrona, gerenciando o loop de eventos em seu aplicativo.

Aqui estão alguns exemplos de bibliotecas de cliente que implementaram o padrão assíncrono:

  • aiohttp – cliente/servidor http para asyncio
  • API de fluxos – primitivos de alto nível assíncronos/em espera para trabalhar com a conexão de rede
  • Fila Janus – fila com reconhecimento de asyncio thread-safe para o Python
  • pyzmq – associações do Python para ZeroMQ
Noções básicas sobre Async no trabalho do Python

Ao definir async na frente de uma assinatura de função, o Python marca a função como uma corrotina. Quando você chama a co-rotina, ela pode ser agendada como uma tarefa em um loop de eventos. Ao chamar await em uma função Async, ele registra uma continuação no loop de eventos, que permite que o loop de eventos processe a próxima tarefa durante o tempo de espera.

Em nosso Trabalho do Python, o trabalho compartilha o loop de eventos com a função do cliente async e é capaz de lidar com várias solicitações simultaneamente. Recomendamos que nossos clientes façam uso de bibliotecas compatíveis com asyncio, como aiohttp e pyzmq. Seguir essas recomendações aumenta a taxa de transferência da função em comparação a essas bibliotecas, quando implementadas de forma síncrona.

Observação

Se sua função for declarada como async sem qualquer await dentro de sua implementação, o desempenho da função será seriamente afetado, pois o loop de eventos será bloqueado, o que proíbe o trabalhador Python de lidar com solicitações simultâneas.

Usar vários processos de trabalho de linguagem

Por padrão, cada instância de host do Functions tem um processo de trabalho de linguagem único. Você pode aumentar o número de processos de trabalho por host (até 10) com a configuração de aplicativo FUNCTIONS_WORKER_PROCESS_COUNT. O Azure Functions tenta distribuir uniformemente invocações de função simultâneas entre esses trabalhos.

Para aplicativos com limite de CPU, é necessário definir o número de trabalhos de linguagem como igual ou superior ao número de núcleos disponíveis por cada aplicativo de funções. Para saber mais, consulte SKUs de instância disponíveis.

Os aplicativos com limite de E/S também podem se beneficiar do aumento do número de processos de trabalho além do número de núcleos disponíveis. Tenha em mente que a definição de um número de funções de trabalho muito alto pode afetar o desempenho geral devido ao aumento do número de alternâncias de contexto necessárias.

A FUNCTIONS_WORKER_PROCESS_COUNT se aplica a cada host que o Azure Functions cria ao escalar horizontalmente seu aplicativo para atender a demanda.

Configurar o máximo de trabalhos em um processo de trabalho de idioma

Conforme mencionado na seção Async, o trabalho de linguagem do Python trata as funções e as corrotinas de forma diferente. Uma corrotina é executada dentro do mesmo loop de eventos em que o trabalho de idioma é executado. Por outro lado, uma invocação de função é executada em um ThreadPoolExecutor, que é mantida pelo operador de idioma, como um thread.

É possível definir o valor máximo de trabalhadores com permissão para executar funções de sincronização usando a configuração PYTHON_THREADPOOL_THREAD_COUNT aplicativo. Esse valor define o max_worker argumento do objeto ThreadPoolExecutor, que permite que o Python use um pool de no máximo max_worker threads para executar chamadas de forma assíncrona. O PYTHON_THREADPOOL_THREAD_COUNT aplica-se a cada trabalho criado pelo host do Functions e o Python decide quando criar um novo thread ou reutilizar o thread ocioso existente. Para versões anteriores do Python (ou seja, 3.8, 3.7 e 3.6), o valor max_worker é definido como 1. Para a versão do Python 3.9 , max_worker é definido como None.

Para aplicativos com limite de CPU, você deve manter a configuração em um número baixo, começando em 1 e aumentar à medida que experimenta a carga de trabalho. Essa sugestão é para reduzir o tempo gasto em alternâncias de contexto e permitir que tarefas com limite de CPU sejam concluídas.

Para aplicativos com limite de E/S, é possível ver ganhos substanciais aumentando o número de threads trabalhando em cada invocação. A recomendação é começar com o padrão do Python (o número de núcleos) + 4 e depois ajustar com base nos valores de taxa de transferência que você está vendo.

Para aplicativos de cargas de trabalho diversas, é necessário balancear as configurações FUNCTIONS_WORKER_PROCESS_COUNT e PYTHON_THREADPOOL_THREAD_COUNT para maximizar a taxa de transferência. Para entender onde seus aplicativos de funções gastam mais tempo, é recomendável criar o perfil deles e definir os valores de acordo com seus comportamentos. Para saber mais sobre essas configurações de aplicativo, consulte Usar vários processos de trabalho.

Observação

Embora essas recomendações se apliquem a funções disparadas tanto por HTTP e não HTTP, talvez seja necessário ajustar outras configurações específicas de gatilho para funções disparadas não HTTP, para obter o desempenho esperado de seus aplicativos de funções. Para obter mais informações sobre isso, consulte estas Melhores práticas para Azure Functions confiáveis.

Gerenciar o loop de eventos

Será necessário usar bibliotecas de terceiros compatíveis com o asyncio. Se nenhuma das bibliotecas de terceiros atender às necessidades, será possível gerenciar os loops de eventos no Azure Functions. Gerenciar loops de eventos proporciona mais flexibilidade no gerenciamento de recursos de computação e também possibilita encapsular bibliotecas de E/S síncronas em corrotinas.

Há muitos documentos oficiais do Python úteis que discutem as Corrotinas e tarefas e o Loop de eventos usando a biblioteca asyncio interna.

Use a biblioteca de solicitações a seguir como exemplo, esse snippet de código usa a biblioteca asyncio para encapsular o método requests.get() em uma corrotina, executando várias solicitações da Web para SAMPLE_URL simultaneamente.

import asyncio
import json
import logging

import azure.functions as func
from time import time
from requests import get, Response


async def invoke_get_request(eventloop: asyncio.AbstractEventLoop) -> Response:
    # Wrap requests.get function into a coroutine
    single_result = await eventloop.run_in_executor(
        None,  # using the default executor
        get,  # each task call invoke_get_request
        'SAMPLE_URL'  # the url to be passed into the requests.get function
    )
    return single_result

async def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    eventloop = asyncio.get_event_loop()

    # Create 10 tasks for requests.get synchronous call
    tasks = [
        asyncio.create_task(
            invoke_get_request(eventloop)
        ) for _ in range(10)
    ]

    done_tasks, _ = await asyncio.wait(tasks)
    status_codes = [d.result().status_code for d in done_tasks]

    return func.HttpResponse(body=json.dumps(status_codes),
                             mimetype='application/json')

Dimensionamento vertical

Você poderá obter mais unidades de processamento, especialmente em operação com limite de CPU, atualizando para um plano premium com especificações mais altas. Com unidades de processamento mais altas, é possível ajustar o número de contagem de processos de trabalho de acordo com o número de núcleos disponíveis e atingir um grau maior de paralelismo.

Próximas etapas

Para obter mais informações sobre o desenvolvimento do Python do Azure Functions consulte os seguintes recursos: