3. Emulação

3.1. Como funciona a emulação no FreeBSD

Como dito anteriormente, o FreeBSD suporta a execução de binários a partir de vários outros UNIX®. Isso funciona porque o FreeBSD tem uma abstração chamada loader de classes de execução. Isso se encaixa na syscall execve(2), então quando execve(2) está prestes a executar um binário que examina seu tipo.

Existem basicamente dois tipos de binários no FreeBSD. Scripts de texto semelhantes a shell que são identificados por #! como seus dois primeiros caracteres e binários normais (normalmente ELF), que são uma representação de um objeto executável compilado. A grande maioria (pode-se dizer todos eles) de binários no FreeBSD é do tipo ELF. Os arquivos ELF contêm um cabeçalho, que especifica a ABI do OS para este arquivo ELF. Ao ler essas informações, o sistema operacional pode determinar com precisão o tipo de binário do arquivo fornecido.

Toda ABI de OS deve ser registrada no kernel do FreeBSD. Isso também se aplica ao sistema operacional nativo do FreeBSD. Então, quando execve(2) executa um binário, ele itera através da lista de APIs registradas e quando ele encontra a correta, ele começa a usar as informações contidas na descrição da ABI do OS (sua tabela syscall, tabela de tradução errno, etc.). Assim, toda vez que o processo chama uma syscall, ele usa seu próprio conjunto de syscalls em vez de uma global. Isso efetivamente fornece uma maneira muito elegante e fácil de suportar a execução de vários formatos binários.

A natureza da emulação de diferentes sistemas operacionais (e também alguns outros subsistemas) levou os desenvolvedores a invitar um mecanismo de evento manipulador. Existem vários locais no kernel, onde uma lista de manipuladores de eventos é chamada. Cada subsistema pode registrar um manipulador de eventos e eles são chamados de acordo com sua necessidade. Por exemplo, quando um processo é encerrado, há um manipulador chamado que possivelmente limpa o que o subsistema que ele precisa de limpeza.

Essas facilidades simples fornecem basicamente tudo o que é necessário para a infra-estrutura de emulação e, de fato, essas são basicamente as únicas coisas necessárias para implementar a camada de emulação do Linux®.

3.2. Primitivas comuns no kernel do FreeBSD

Camadas de emulação precisam de algum suporte do sistema operacional. Eu vou descrever algumas das primitivas suportadas no sistema operacional FreeBSD.

3.2.1. Primitivas de Bloqueio

Contribuído por: Attilio Rao

O conjunto de primitivas de sincronização do FreeBSD é baseado na idéia de fornecer um grande número de diferentes primitivas de uma maneira que a melhor possa ser usada para cada situação específica e apropriada.

Para um ponto de vista de alto nível, você pode considerar três tipos de primitivas de sincronização no kernel do FreeBSD:

  • operações atômicas e barreiras de memória

  • locks

  • barreiras de agendamento

Abaixo, há descrições para as 3 famílias. Para cada bloqueio, você deve verificar a página de manual vinculada (onde for possível) para obter explicações mais detalhadas.

3.2.1.1. Operações atômicas e barreiras de memória

Operações atômicas são implementadas através de um conjunto de funções que executam aritmética simples em operandos de memória de maneira atômica com relação a eventos externos (interrupções, preempção, etc.). Operações atômicas podem garantir atomicidade apenas em pequenos tipos de dados (na ordem de magnitude do tipo de dados C da arquitetura .long.), portanto raramente devem ser usados ​​diretamente no código de nível final, se não apenas para operações muito simples (como configuração de flags em um bitmap, por exemplo). De fato, é bastante simples e comum escrever uma semântica errada baseada apenas em operações atômicas (geralmente referidas como lock-less). O kernel do FreeBSD oferece uma maneira de realizar operações atômicas em conjunto com uma barreira de memória. As barreiras de memória garantirão que uma operação atômica ocorrerá seguindo alguma ordem especificas em relação a outros acessos à memória. Por exemplo, se precisarmos que uma operação atômica aconteça logo depois que todas as outras gravações pendentes (em termos de instruções reordenando atividades de buffers) forem concluídas, precisamos usar explicitamente uma barreira de memória em conjunto com essa operação atômica. Portanto, é simples entender por que as barreiras de memória desempenham um papel fundamental na construção de bloqueios de alto nível (assim como referências, exclusões mútuas, etc.). Para uma explicação detalhada sobre operações atômicas, consulte atomic(9). É muito, no entanto, notar que as operações atômicas (e as barreiras de memória também) devem, idealmente, ser usadas apenas para construir bloqueios front-ending (como mutexes).

3.2.1.2. Refcounts

Refcounts são interfaces para manipular contadores de referência. Eles são implementados por meio de operações atômicas e destinam-se a ser usados ​​apenas para casos em que o contador de referência é a única coisa a ser protegida, portanto, até mesmo algo como um spin-mutex é obsoleto. Usar a interface de recontagem para estruturas, onde um mutex já é usado, geralmente está errado, pois provavelmente devemos fechar o contador de referência em alguns caminhos já protegidos. Uma manpage discutindo refcount não existe atualmente, apenas verifique sys/refcount.h para uma visão geral da API existente.

3.2.1.3. Locks

O kernel do FreeBSD tem enormes classes de bloqueios. Cada bloqueio é definido por algumas propriedades peculiares, mas provavelmente o mais importante é o evento vinculado a detentores de contestação (ou, em outros termos, o comportamento de threading incapazes de adquirir o bloqueio). O esquema de bloqueio do FreeBSD apresenta três comportamentos diferentes para contendores:

  1. spinning

  2. blocking

  3. sleeping

Nota:

números não são casuais

3.2.1.4. Spinning locks

Spin locks permitem que os acumuladores rotacionarem até que eles não consigam adquirir um lock. Uma questão importante é quando um segmento contesta em um spin lock se não for desmarcado. Uma vez que o kernel do FreeBSD é preventivo, isto expõe o spin lock ao risco de deadlocks que podem ser resolvidos apenas desabilitando as interrupções enquanto elas são adquiridas. Por essa e outras razões (como falta de suporte à propagação de prioridade, falta de esquemas de balanceamento de carga entre CPUs, etc.), os spin locks têm a finalidade de proteger endereçamentos muito pequenos de código ou, idealmente, não serem usados ​​se não solicitados explicitamente ( explicado posteriormente).

3.2.1.5. Bloqueio

Os locks em blocos permitem que as tarefas dos acumuladores sejam removidas e bloqueados até que o proprietário do bloqueio não os libere e ative um ou mais contendores. Para evitar problemas de fome, os locks em bloco fazem a propagação de prioridade dos acumuladores para o proprietário. Os locks em bloco devem ser implementados por meio da interface turnstile e devem ser o tipo mais usado de bloqueios no kernel, se nenhuma condição específica for atendida.

3.2.1.6. Sleeping

Sleep locks permitem que as tarefas dos waiters sejam removidas e eles adormecem até que o suporte do lock não os deixe cair e desperte um ou mais waiters. Como os sleep locks se destinam a proteger grandes endereçamentos de código e a atender a eventos assíncronos, eles não fazem nenhuma forma de propagação de prioridade. Eles devem ser implementados por meio da interface sleepqueue(9).

A ordem usada para adquirir locks é muito importante, não apenas pela possibilidade de deadlock devido a reversões de ordem de bloqueio, mas também porque a aquisição de lock deve seguir regras específicas vinculadas a naturezas de bloqueios. Se você der uma olhada na tabela acima, a regra prática é que, se um segmento contiver um lock de nível n (onde o nível é o número listado próximo ao tipo de bloqueio), não é permitido adquirir um lock de níveis superiores , pois isso quebraria a semântica especificada para um caminho. Por exemplo, se uma thread contiver um lock em bloco (nível 2), ele poderá adquirir um spin lock (nível 1), mas não um sleep lock (nível 3), pois os locks em bloco são destinados a proteger caminhos menores que o sleep lock ( essas regras não são sobre operações atômicas ou agendamento de barreiras, no entanto).

Esta é uma lista de bloqueio com seus respectivos comportamentos:

Entre esses bloqueios, apenas mutexes, sxlocks, rwlocks e lockmgrs são destinados a tratar recursão, mas atualmente a recursão é suportada apenas por mutexes e lockmgrs.

3.2.1.7. Barreiras de agendamento

As barreiras de agendamento devem ser usadas para orientar o agendamento de threads. Eles consistem principalmente de três diferentes stubs:

  • seções críticas (e preempção)

  • sched_bind

  • sched_pin

Geralmente, eles devem ser usados ​​apenas em um contexto específico e, mesmo que possam substituir bloqueios, eles devem ser evitados porque eles não permitem o diagnóstico de problemas simples com ferramentas de depuração de bloqueio (como witness(4)).

3.2.1.8. Seções críticas

O kernel do FreeBSD foi feito basicamente para lidar com threads de interrupção. De fato, para evitar latência de interrupção alta, os segmentos de prioridade de compartilhamento de tempo podem ser precedidos por threads de interrupção (dessa forma, eles não precisam aguardar para serem agendados como as visualizações de caminho normais). Preempção, no entanto, introduz novos pontos de corrida que precisam ser manipulados também. Muitas vezes, para lidar com a preempção, a coisa mais simples a fazer é desativá-la completamente. Uma seção crítica define um pedaço de código (delimitado pelo par de funções critical_enter(9) e critical_exit(9), onde é garantido que a preempção não aconteça (até que o código protegido seja totalmente executado) Isso pode substituir um bloqueio efetivamente, mas deve ser usado com cuidado para não perder toda a vantagem essa preempção traz.

3.2.1.9. sched_pin/sched_unpin

Outra maneira de lidar com a preempção é a interface sched_pin(). Se um trecho de código é fechado no par de funções sched_pin() e sched_unpin(), é garantido que a respectiva thread, mesmo que possa ser antecipada, sempre ser executada na mesma CPU. Fixar é muito eficaz no caso particular quando temos que acessar por dados do cpu e assumimos que outras threads não irão alterar esses dados. A última condição determinará uma seção crítica como uma condição muito forte para o nosso código.

3.2.1.10. sched_bind/sched_unbind

sched_bind é uma API usada para vincular uma thread a uma CPU em particular durante todo o tempo em que ele executa o código, até que uma chamada de função sched_unbind não a desvincule. Esse recurso tem um papel importante em situações em que você não pode confiar no estado atual das CPUs (por exemplo, em estágios iniciais de inicialização), já que você deseja evitar que sua thread migre em CPUs inativas. Como sched_bind e sched_unbind manipulam as estruturas internas do agendador, elas precisam estar entre a aquisição/liberação de sched_lock quando usadas.

3.2.2. Estrutura Proc

Várias camadas de emulação exigem alguns dados adicionais por processo. Ele pode gerenciar estruturas separadas (uma lista, uma árvore etc.) contendo esses dados para cada processo, mas isso tende a ser lento e consumir memória. Para resolver este problema, a estrutura proc do FreeBSD contém p_emuldata, que é um ponteiro vazio para alguns dados específicos da camada de emulação. Esta entrada proc é protegida pelo mutex proc.

A estrutura proc do FreeBSD contém uma entrada p_sysent que identifica, qual ABI este processo está executando. Na verdade, é um ponteiro para o sysentvec descrito acima. Portanto, comparando esse ponteiro com o endereço em que a estrutura sysentvec da ABI especificada está armazenada, podemos efetivamente determinar se o processo pertence à nossa camada de emulação. O código normalmente se parece com:

if (__predict_true(p->p_sysent != &elf_Linux®_sysvec))
	  return;

Como você pode ver, usamos efetivamente o modificador __predict_true para recolher o caso mais comum (processo do FreeBSD) para uma operação de retorno simples, preservando assim o alto desempenho. Este código deve ser transformado em uma macro porque atualmente não é muito flexível, ou seja, não suportamos emulação Linux®64 nem processa A.OUT Linux® em i386.

3.2.3. VFS

O subsistema FreeBSD VFS é muito complexo, mas a camada de emulação Linux® usa apenas um pequeno subconjunto através de uma API bem definida. Ele pode operar em vnodes ou manipuladores de arquivos. Vnode representa um vnode virtual, isto é, representação de um nó no VFS. Outra representação é um manipulador de arquivos, que representa um arquivo aberto da perspectiva de um processo. Um manipulador de arquivos pode representar um socket ou um arquivo comum. Um manipulador de arquivos contém um ponteiro para seu vnode. Mais de um manipulador de arquivos pode apontar para o mesmo vnode.

3.2.3.1. namei

A rotina namei(9) é um ponto de entrada central para a pesquisa e o nome do caminho. Ele percorre o caminho ponto a ponto do ponto inicial até o ponto final usando a função de pesquisa, que é interna ao VFS. A syscall namei(9) pode lidar com links simbólicos, absolutos e relativos. Quando um caminho é procurado usando namei(9) ele é inserido no cache de nomes. Esse comportamento pode ser suprimido. Essa rotina é usada em todo o kernel e seu desempenho é muito crítico.

3.2.3.2. vn_fullpath

A função vn_fullpath(9) faz o melhor esforço para percorrer o cache de nomes do VFS e retorna um caminho para um determinado vnode (bloqueado). Esse processo não é confiável, mas funciona bem nos casos mais comuns. A falta de confiabilidade é porque ela depende do cache do VFS (ele não atravessa as estruturas intermediárias), não funciona com hardlinks, etc. Essa rotina é usada em vários locais no Linuxulator.

3.2.3.3. Operações de vnode
  • fgetvp - dado um encadeamento e um número de descritor de arquivo, ele retorna o vnode associado

  • vn_lock(9) - bloqueia um vnode

  • vn_unlock - desbloqueia um vnode

  • VOP_READDIR(9) - lê um diretório referenciado por um vnode

  • VOP_GETATTR(9) - obtém atributos de um arquivo ou diretório referenciado por um vnode

  • VOP_LOOKUP(9) - procura um caminho para um determinado diretório

  • VOP_OPEN(9) - abre um arquivo referenciado por um vnode

  • VOP_CLOSE(9) - fecha um arquivo referenciado por um vnode

  • vput(9) - decrementa a contagem de uso para um vnode e o desbloqueia

  • vrele(9) - diminui a contagem de uso para um vnode

  • vref(9) - incrementa a contagem de uso para um vnode

3.2.3.4. Operações do manipulador de arquivos
  • fget - dado uma thread e um número de file descriptor, ele retorna o manipulador de arquivos associado e faz referência a ele

  • fdrop - elimina uma referência a um manipulador de arquivos

  • fhold - faz referência a um manipulador de arquivos

All FreeBSD documents are available for download at https://download.freebsd.org/ftp/doc/

Questions that are not answered by the documentation may be sent to <freebsd-questions@FreeBSD.org>.
Send questions about this document to <freebsd-doc@FreeBSD.org>.