Este artigo foi traduzido por máquina.

Windows com C++

Multitarefa cooperativa leve com C++

Kenny Kerr

 

Kenny KerrSe você trabalha para uma empresa que tem um desses documentos de padrões que iria aniquilar uma floresta inteira fosse sempre a ser impresso de codificação, você seria melhor parar de ler agora. As possibilidades são, o que estou prestes a mostrar a você violará muitas das vacas sagradas no épico acima mencionado. Vou falar sobre uma técnica particular que eu originalmente desenvolvido para permitir-me escrever código completamente assíncrono com eficiência e sem a necessidade de máquinas de estado complexo.

A menos que seu nome é Donald Knuth, é provável que qualquer código que você escreve se parecerá com algo que já foi feito. Na verdade, a conta mais antiga, que acho a técnica que descrevo aqui é uma menção por Knuth-se, e é o mais recente por um cavalheiro da Inglaterra com o nome de Simon Tatham, conhecido pelo popular emulador de terminal PuTTY. No entanto, parafraseando o juiz na recente Oracle v. Disputa do Google, "você não pode patentear um loop for." Ainda assim, estamos todos endividados para nossos pares no campo da programação de computadores, como podemos avançar o ofício.

Antes de eu mergulhar e descrever o que a minha técnica é e como funciona, preciso apresentar um desvio rápido na esperança de que ele vai te dar um pouco mais perspectiva para o que está por vir. Em "compiladores: Princípios, técnicas e ferramentas, segunda edição"(Prentice Hall, 2006) por Alfred V. Aho, Monica S. Lam, Ravi Sethi e serviço de D. Jeffrey Ullman — mais conhecido como o livro do dragão — os autores resumem o propósito de um compilador como sendo um programa que possa ler um programa em um idioma e traduzi-lo em um programa equivalente em outro idioma. Se você perguntar a designer de C++ Bjarne Stroustrup que língua ele costumava escrever C++, que ele iria dizer-lhe que era o C++. O que isto significa é que ele escreveu um pré-processador que ler C++ e produzido C, um compilador c padrão, em seguida, poderia ainda traduzir em alguma linguagem de máquina. Se você olhar perto o suficiente, você pode ver as variantes desta idéia em muitos lugares. O compilador c#, por exemplo, traduz a palavra-chave yield aparentemente mágico em uma classe regular que implementa um iterador. No ano passado, notei que o compilador de Visual C++ primeiramente traduzido porções do C + + sintaxe CX em C++ padrão antes de compilá-lo ainda mais. Isso pode ter mudado desde então, mas o ponto é que os compiladores são fundamentalmente sobre a tradução.

Às vezes, ao usar um determinado idioma, você pode concluir que um recurso seria desejável, mas a própria natureza da linguagem proíbe você de implementá-lo. Isso raramente acontece em C++, porque a linguagem de design é adequada para a expressão de diferentes técnicas, graças a sua sintaxe rica e instalações de meta-programação. No entanto, isso de fato aconteceu comigo.

Passei a maior parte do meu tempo estes dias trabalhando em um sistema operacional embutido que criei do zero. Linux, Windows nem qualquer outro sistema operacional é executado sob as tampas. Confio sem qualquer tipo de software de código aberto, e é que normalmente seria impossível enfim por motivos que se tornará claros. Isto abriu meus olhos para um mundo de programação c e C++ que é completamente diferente do mundo tradicional de desenvolvimento de software de PC. Mais sistemas embarcados têm restrições muito diferentes dos programas "normais". Eles têm que ser extremamente confiável. Falha pode ser cara. Os usuários raramente estão em torno de "reiniciar" um programa falha. Sistemas podem ter duração de anos ininterruptos e sem intervenção humana. Imagine um mundo sem Windows Update ou similares. Esses sistemas também podem ter relativamente escassos recursos de computação. Exatidão, confiabilidade e, em particular, simultaneidade todos desempenham um papel central na concepção de tais sistemas.

Como tal, o mundo luxuoso de Visual C++, com suas bibliotecas poderosas, raramente é adequado. Mesmo que Visual C++ direcionar meu microcontrolador incorporado, as bibliotecas que acompanham não são adequadas para sistemas com tão poucos recursos de computação e, muitas vezes, difícil restrições em tempo real. Um dos microprocessadores que eu estou usando atualmente tem apenas 32 KB de memória RAM rodando a menos de 50 MHz, e este é ainda luxuoso para alguns na Comunidade incorporada. Deve ficar claro que por "embedded" não quero dizer seu smartphone média com um meia-giga de RAM e um processador de 1 GHz.

Em "programação: Princípios e prática usando C++ "(Addison-Wesley Professional, 2008), Stroustrup cita alocações de armazenamento grátis (por exemplo, novo e excluir), exceções e dynamic_cast em uma pequena lista de características que devem ser evitados em sistemas embarcados mais porque eles não são previsíveis. Infelizmente, que impede o uso da maior parte da norma, fornecedor e abrir fonte C++ bibliotecas disponíveis hoje.

O resultado é que a programação mais incorporada — e para que o assunto, de modo kernel programação em Windows — ainda emprega c ao invés de C++. Dado que o c é essencialmente um subconjunto do C++, eu costumo usar um compilador de C++, mas manter um subconjunto estrito da linguagem que é previsível, portátil e funciona bem em sistemas embarcados.

Isso me levou a uma viagem para encontrar uma técnica adequada para permitir a simultaneidade no meu SO pouco incorporado. Até este ponto, meu so tinha um único segmento, se você pode chamá-lo assim. Não há nenhum bloqueio de operações, para interromper a qualquer momento eu precisava implementar algo que pode demorar algum tempo, como à espera de uma e/S de armazenamento ou para um tempo limite de retransmissão de rede, eu precisaria escrever uma máquina de estado cuidadosamente construída. Esta é prática padrão com sistemas orientados a eventos, mas que resulta em código que é difícil raciocinar através de logicamente, porque o código não é seqüencial.

Imagine um driver de armazenamento. Pode haver uma função de storage_read para ler uma página de memória de armazenamento persistente de flash do dispositivo. A função storage_read pode verificar primeiro se o periférico ou ônibus está ocupado, e se assim for, basta enfileirar a solicitação de leitura antes de retornar. Em algum ponto periférico e poderá tornar-se livre de autocarro e a solicitação. Isso pode envolver a desativação do transceptor, Formatando um comando apropriado para o ônibus, preparando o direct memory acesso buffers, permitindo que o transceptor novamente e, em seguida, retornar para permitir que o chamador para fazer outra coisa enquanto a transferência é concluída em hardware. Eventualmente o autocarro sinaliza sua conclusão e o chamador é notificado através de algum mecanismo de retorno de chamada, e prosseguir quaisquer outras solicitações na fila. Escusado será dizer, pode ficar bastante complicado gerenciar as filas, retornos de chamada e máquinas de estado. Verificar se que tudo está correto está ainda mais difícil. Eu mesmo não tenho descrito como um sistema de arquivos sem bloqueio pode ser implementado em cima essa abstração, ou como um servidor Web pode usar o sistema de arquivos para servir dados — tudo sem nunca bloquear. Uma abordagem diferente é necessária para reduzir a complexidade crescente e inevitável.

Agora, imagine que o C++ tinha algumas palavras-chave que permitem transportar o processador da pilha de chamadas de um para outro em mid-function. Imagine o seguinte código C++ hipotético:

void storage_read(storage_args & args) async
{
  wait while (busy);
  busy = true;
  begin_transfer(args);
  yield until (done_transmitting());
  busy = false;
}

Observe o palavra-chave contextual de "async" depois que a lista de parâmetro. Eu também estou usando palavras-chave espaçadas imaginário dois chamados "Aguarde enquanto o" e "rendimento até." Considere o que isso significa para o C++ tem tais palavras-chave. O compilador teria alguma forma expressar a noção de um interlúdio, se quiserem. Knuth chamou isso de um coroutine. A palavra-chave async pode aparecer com uma declaração de função para informar o compilador que a função pode executar de forma assíncrona e, portanto, deve ser chamada apropriadamente. O hipotética espera e palavras-chave de rendimento é os pontos reais em que a função deixa de executar de forma síncrona e potencialmente pode voltar para o chamador, só para continuar de onde parou em um estágio posterior. Você também poderia imaginar uma "esperar" palavra-chave condicional, bem como uma declaração de rendimento incondicional.

Eu vi alternativas a esta forma de cooperação de simultaneidade — nomeadamente a biblioteca de agentes assíncronos incluído com o Visual C++ — mas todas as soluções que encontrei dependiam de algum mecanismo de agendamento de tempo de execução. O que proponho aqui e irá ilustrar em um momento é que é perfeitamente possível que um compilador de C++ realmente pode fornecer a simultaneidade de cooperativa sem qualquer custo de qualquer tipo de tempo de execução. Tenha em mente que não estou dizendo que isso vai resolver o desafio de escalabilidade de vários núcleos. O que estou dizendo é que deve ser capaz de escrever rápidos e ágil de programas orientados a eventos sem envolver um Agendador. E como acontece com as linguagens c e C++ existente, nada deve impedir essas técnicas sejam utilizados juntamente com OS segmentos ou outros tempos de execução de simultaneidade.

Obviamente, C++ não suporta o que eu estou descrevendo agora. O que descobri, no entanto, é que ele pode ser simulado usando razoavelmente bem padrão c ou C++ e sem recorrer a artifícios de montador. Usando essa abordagem, a função de storage_read descrita anteriormente pôde olhar como segue:

task_begin(storage_read, storage_args & args)
{
  task_wait_while(busy);
  busy = true;
  begin_transfer(args);
  task_yield_until(done_transmitting());
  busy = false;
}
task_end

Obviamente, eu estou contando com macros aqui. Suspiro! Claramente, isso viola o item 16 no C++ Coding Standards (bit.ly/8boiZ0), mas as alternativas são piores. A solução ideal é o idioma dar suporte a isso diretamente. Alternativas incluem o uso de longjmp, mas acho que é pior e tem suas próprias armadilhas. Outra abordagem pode ser usar a linguagem assembly, mas então eu iria perder todos portabilidade. É discutível se isso poderia até mesmo ser feito tão eficientemente em linguagem assembly, porque que provavelmente resultará em uma solução que usava mais memória devido à perda de informações contextuais e a implementação de uma pilha por tarefa inevitável. Humor-me assim como eu mostrar a você como é simples e eficaz, que isto é, e então como tudo funciona.

Para manter as coisas claras, doravante, vou chamar essas tarefas de funções assíncronas. Dada a tarefa que descrevi anteriormente, pode ser agendada simplesmente chamando-o como uma função:

storage_args = { ...
};
storage_read(args);

Tão logo a tarefa decide que ele não pode proceder, ele simplesmente irá retornar para o chamador. Tarefas de empregam um tipo de retorno de bool para indicar aos chamadores se eles já concluída. Assim, você continuamente poderia agendar uma tarefa até a sua conclusão, como segue:

while (storage_read(args));

Claro, isso iria bloquear o chamador até que a tarefa seja concluída. Isso pode realmente ser apropriado, talvez quando o programa inicia-se pela primeira vez para carregar um arquivo de configuração ou similares. Além dessa exceção, raramente você iria querer bloquear dessa maneira. O que você precisa é uma maneira de esperar para uma tarefa de forma cooperativa:

task_wait_for(storage_read, args);

Isso pressupõe que o chamador é em si uma tarefa e, em seguida, renderá ao seu chamador até que a tarefa aninhada for concluída, em que ponto ele vai continuar. Agora deixe-me definir vagamente a tarefa palavras-chave, ou pseudofunções e depois ir através de um exemplo ou dois que você pode realmente tentar por si mesmo:

  • task_declare (nome, parâmetro) declara uma tarefa, normalmente em um arquivo de cabeçalho.
  • task_begin (nome, parâmetro) inicia a definição de uma tarefa, normalmente em um arquivo de origem do C++.
  • task_endEnds a definição de uma tarefa.
  • task_return () termina a execução de uma tarefa e retorna o controle para o chamador.
  • task_wait_until (expressão) aguarda até que a expressão é verdadeira antes de continuar.
  • task_wait_while (expressão) espera enquanto a expressão for verdadeira, antes de continuar.
  • task_wait_for (nome, parâmetro) executa a tarefa e espera por ele concluir antes de continuar.
  • task_yield () rendimentos controlam incondicionalmente, continuando, quando a tarefa é reagendada.
  • task_yield_until (expressão) rendimentos controlam pelo menos uma vez, continuando, quando a expressão é diferente de zero.

É importante lembrar que nenhuma dessas rotinas Bloquear de qualquer maneira. Eles são todos projetados para alcançar um elevado grau de simultaneidade de forma cooperativa. Deixe-me ilustrar com um exemplo simples. Este exemplo usa duas tarefas, uma para solicitar ao usuário para um número e outro calcular uma média aritmética simple dos números que chegam. Primeiro é a tarefa de média, mostrada na Figura 1.

Figura 1 A tarefa média

struct average_args
{
  int * source;
  int sum;
  int count;
  int average;
  int task_;
};
task_begin(average, average_args & args)
{
  args.sum = 0;
  args.count = 0;
  args.average = 0;
  while (true)
  {
    args.sum += *args.source;
    ++args.count;
    args.average = args.sum / args.count;
    task_yield();
  }
}
task_end

Uma tarefa aceita exatamente um argumento que deve ser passado por referência e deve incluir uma variável de membro inteiro chamada Task _. Obviamente, este é algo que o compilador iria esconder do chamador, dado o cenário ideal de suporte de idioma de primeira classe. No entanto, para efeitos desta simulação, preciso de uma variável para controlar o andamento da tarefa. O chamador precisa apenas é inicializá-lo a zero.

A tarefa em si é interessante que ele contém um infinito ciclo com uma chamada de task_yield dentro do corpo do loop while. A tarefa Inicializa alguns estado antes de entrar neste ciclo. Ele atualiza seus agregados e rendimentos, permitindo que outras tarefas a serem executadas antes de repetir indefinidamente.

Em seguida é a tarefa de entrada, como mostrado na Figura 2.

Figura 2: A tarefa de entrada

struct input_args
{
  int * target;
  int task_;
};
task_begin(input, input_args & args)
{
  while (true)
  {
    printf("number: ");
    if (!scanf_s("%d", args.target))
    {
      task_return();
    }
    task_yield();
  }
}
task_end

Esta tarefa é interessante que ele ilustra que tarefas podem bloquear na verdade, como o scanf_s função vai fazer enquanto espera para a entrada. Embora não ideal para um sistema orientado a eventos. Essa tarefa também ilustra usando a chamada de task_return para completar a tarefa em mid-function, em vez de usar uma expressão condicional while instrução. Uma tarefa é concluída por chamada task_return ou cair fora da final da função, por assim dizer. De qualquer maneira, o chamador vai ver isto como completar a tarefa, e ele será capaz de retomar.

Para reduzir estas tarefas a vida, tudo que você precisa é uma simple função principal para agir como um Agendador:

int main()
{
  int share = -1;
  average_args aa = { &share };
  input_args ia = { &share };
  while (input(ia))
  {
    average(aa);
    printf("sum=%d count=%d average=%d\n",
      aa.sum, aa.count, aa.average);
  }
}

As possibilidades são infinitas. Você poderia escrever tarefas representando temporizadores, produtores e consumidores, manipuladores de ligação de TCP e muito mais.

Então, como funciona? Primeiro lembre-se, novamente, que a solução ideal é para o compilador para implementar isso, caso em que ele pode usar todos os tipos de truques inteligentes para implementar de forma eficiente, e o que estou prestes a descrever realmente não vai ser em qualquer lugar próximo tão sofisticados ou complicado.

Como melhor como eu posso dizer, isso se resume a uma descoberta por um programador chamado Tom Duff, que descobri que você pode jogar truques inteligentes com a instrução switch. Enquanto é sintaticamente válido, você pode aninhar vários seleção ou instruções de iteração dentro de uma instrução switch para efetivamente saltar dentro e fora de uma função em vão. Duff publicou uma técnica de panificação de loop manual e Tatham então percebi que ele poderia ser usado para simular coroutines. Levei essas ideias e implementadas tarefas como segue.

As macros task_begin e task_end Definirm a instrução switch circundante:

#define task_begin(name, parameter) \
                                    \
  bool name(parameter)              \
  {                                 \
    bool yield_ = false;            \
    switch (args.task_)             \
    {                               \
      case 0:
#define task_end                    \
                                    \
    }                               \
    args.task_ = 0;                 \
    return false;                   \
  }

Agora deve ser óbvio que a Task _ única variável é para e como tudo funciona. Inicializar Task _ a zero garante que a execução salta para o início da tarefa. Quando uma tarefa termina, ele tem novamente definido como zero, como uma conveniência, para que a tarefa pode ser reiniciada facilmente. Dado que, a macro task_wait_until fornece a posição do salto necessário e cooperativa facilidade de retorno:

#define task_wait_until(expression)      \
                                         \
  args.task_ = __LINE__; case __LINE__:  \
  if (!(expression))                     \
  {                                      \
    return true;                         \
  }

Task _ variável é definida como a macro de número de linha predefinida e a instrução case Obtém o mesmo número de linha, garantindo assim que, se a tarefa produz, será retomada na próxima vez em que foi designado o código direito de onde parou. As restantes macros são mostradas em Figura 3.

Figura 3 as restantes Macros

#define task_return()                    \
                                         \
  args.task_ = 0;                        \
  return false;
#define task_wait_while(expression)      \
                                         \
  task_wait_until(!(expression))
#define task_wait_for(name, parameter)   \
                                         \
  task_wait_while(name(parameter))
#define task_yield_until(expression)     \
                                         \
  yield_ = true;                         \
  args.task_ = __LINE__; case __LINE__:  \
  if (yield_ || !(expression))           \
  {                                      \
    return true;                         \
  }
#define task_yield()                     \
                                         \
  task_yield_until(true)

Estes devem ser evidentes. Talvez a sutileza só vale a pena mencionar é task_yield_until, que é semelhante ao task_wait_until, mas pelo fato de que ela sempre irá produzir pelo menos uma vez. task_yield, por sua vez, só que nunca produzirá exatamente uma vez, e estou confiante de que qualquer compilador respeitável otimizará distância minha estenografia. Devo mencionar que task_wait_until também é uma ótima maneira de lidar com condições de falta de memória. Em vez de falhar em alguma operação profundamente aninhada com duvidosa capacidade de recuperação, você pode simplesmente render até a alocação de memória é bem-sucedida, dando oportunidade para completar e espero que libere memória muito necessária de outras tarefas. Novamente, isso é fundamental para sistemas embarcados onde a falha não é uma opção.

Dado que eu estou emulando coroutines, há algumas armadilhas. Você não pode utilizar com segurança a variáveis locais dentro de tarefas, e qualquer código que viola a validade da instrução switch oculto vai causar problemas. Ainda assim, dado que pode definir meu próprio task_args — e Considerando que quanto mais simples o meu código é graças a essa técnica — eu sou grato que ele funciona tão bem como ele faz.

Achei útil para desativar os seguintes avisos de compilador do Visual C++:

#pragma warning(disable: 4127) // Conditional expression is constant
#pragma warning(disable: 4189) // Local variable is initialized but not referenced
#pragma warning(disable: 4706) // Assignment within conditional expression

Finalmente, se você estiver usando o Visual C++ IDE, certifique-se de desabilitar o "Editar e continuar a depuração" usando Zi em vez de Zi.

Como concluí que esta coluna, olhou em torno da Web para quaisquer iniciativas semelhantes e encontrei o novo async e esperam por palavras-chave que introduziu o Visual Studio 2012 compilador c#. Em muitos aspectos, isso é uma tentativa de resolver um problema semelhante. Espero que a Comunidade de C++ para seguir o terno. A questão é se estas soluções virão para c e C++ em uma maneira que produzirá previsível código adequado para sistemas embarcados como eu descrevi nesta coluna, ou se eles vai contar com um runtime de simultaneidade, como o atual Visual C++ bibliotecas fazem. Minha esperança é que um dia eu vou ser capaz de jogar fora essas macros, mas até esse dia chegar, eu vou permanecer produtivo com essa técnica de leve, cooperativa e multitarefa.

Fique atento para a próxima edição do Windows com o C++, em que eu vou te mostrar algumas técnicas novas que Niklas Gustafsson e Artur Laksberg da equipe do Visual C++ tem vindo a trabalhar para trazer retomável funções c++.

Kenny Kerr é um profissional de fabricação de software apaixonado pelo desenvolvimento nativo para Windows. Entre em contato com Kenny em kennykerr.ca.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Artur Laksberg