Compartilhar via


Tudo sobre CLR

Práticas recomendadas de interoperabilidade entre código gerenciado e nativo

Jesse Kaplan

Sumário

Quando a interoperabilidade entre código gerenciado e nativo é adequada?
Tecnologias de interoperabilidade: três opções
Tecnologias de interoperabilidade: P/Invoke
Tecnologias de interoperabilidade: interoperabilidade COM
Tecnologias de interoperabilidade: C++/CLI
Considerações para a sua arquitetura de interoperabilidade
Projeto de API e a experiência do desenvolvedor
Desempenho e a localização do limite de interoperabilidade
Gerenciamento da vida útil

Sob alguns aspectos, pode parecer estranho ver uma coluna como esta na MSDN Magazine bem no início de 2009 — a interoperabilidade entre código gerenciado e nativo têm tido suporte no Microsoft .NET Framework, mais ou menos da mesma forma, desde a versão 1.0, de 2002. Além disso, você encontra prontamente documentos detalhados sobre APIs e ferramentas e milhares de páginas de documentação de apoio detalhada. No entanto, falta uma orientação abrangente de alto nível sobre a arquitetura que descreva quando usar a interoperabilidade, considerações de arquitetura a serem observadas e a tecnologia de interoperabilidade que deve ser usada. Esta é a lacuna. Vou começar a preenchê-la aqui.

Quando a interoperabilidade entre código gerenciado e nativo é adequada?

Não há muito material escrito sobre quando é apropriado utilizar a interoperabilidade entre código gerenciado e nativo, e grande parte do material disponível sobre o assunto é conflitante. Da mesma forma, muitas vezes a orientação não se baseia na experiência prática propriamente dita. Então, antes de começar, quero dizer que tudo o que estou escrevendo hoje é orientação desenvolvida com base nas experiências que a equipe de interoperabilidade tem tido ajudando clientes internos e externos de todos os portes.

Para resumir essa experiência, reunimos três produtos que são excelentes exemplos de usos bem-sucedidos da interoperabilidade e formam um conjunto representativo dos tipos de usos dessa tecnologia. O Visual Studio Tools for Office é o conjunto de ferramentas de extensibilidade gerenciada para Office e o primeiro aplicativo que me vem à mente quando penso em interoperabilidade. Ele representa o uso clássico da interoperabilidade: um aplicativo nativo grande que quer habilitar extensões ou suplementos gerenciados. O próximo da minha lista é o Windows Media Center, um aplicativo que foi criado desde o início como uma combinação de aplicativo gerenciado e nativo. O Windows Media Center foi desenvolvido principalmente com código gerenciado e algumas partes (as que lidam diretamente com o sintonizador de TV e outros drivers de hardware) de código nativo interno. Por fim, temos o Expression Design, um aplicativo com uma grande base de código nativo preexistente que quer aproveitar novas tecnologias gerenciadas, neste caso o Windows Presentation Foundation (WPF), para oferecer ao usuário uma experiência da próxima geração.

Esses três aplicativos abordam os três motivos mais comuns para se usar a interoperabilidade: permitir a extensibilidade gerenciada de aplicativos nativos preexistentes, permitir que a maior parte de um aplicativo aproveite os benefícios do código gerenciado e, ao mesmo tempo, grave as partes de nível mais baixo no código nativo e agregar uma experiência de usuário de próxima geração diferenciada a um aplicativo nativo existente.

Para esses casos, a orientação disponível anteriormente teria sugerido simplesmente regravar todo o aplicativo em código gerenciado. Depois de tentar seguir esse conselho e ver quantas pessoas simplesmente se recusam a segui-lo, chegamos à conclusão de que esta não é uma opção para a maioria dos aplicativos existentes. A interoperabilidade vai ser a tecnologia vital que permitirá aos desenvolvedores manter o investimento em código nativo e aproveitar o novo ambiente gerenciado. Se você está pensando em regravar seu aplicativo por outros motivos, o código gerenciado pode ser uma boa opção. Mas normalmente as pessoas não querem regravar seus aplicativos só para usar novas tecnologias gerenciadas e evitar a interoperabilidade.

Tecnologias de interoperabilidade: três opções

Existem três principais tecnologias de interoperabilidade no .NET Framework, e a sua escolha será determinada em parte pelo tipo de API que você está usando para interoperabilidade e em parte pelos seus requisitos e a necessidade de controlar o limite. Platform Invoke, ou P/Invoke, é basicamente uma tecnologia de interoperabilidade de código gerenciado para nativo que permite chamar APIs nativas no estilo C de código gerenciado. A interoperabilidade COM é uma tecnologia que permite consumir interfaces COM nativas de código gerenciado ou exportar essas interfaces a partir de APIs gerenciadas. Por fim, temos a C++/CLI (anteriormente chamada de C++ gerenciado), com a qual é possível criar assemblies que contêm uma combinação de código compilado C++ gerenciado e nativo e funciona como uma ponte entre o código gerenciado e o nativo.

Tecnologias de interoperabilidade: P/Invoke

A P/Invoke é a mais simples das três tecnologias e foi projetada principalmente para oferecer acesso gerenciado a APIs no estilo C. Com a P/Invoke, você precisa encapsular cada API individualmente. Ela pode ser uma ótima escolha se você tiver algumas APIs para encapsular e se as respectivas assinaturas não forem muito complexas. No entanto, o uso da P/Invoke fica consideravelmente mais difícil se as APIs não gerenciadas têm muitos argumentos que não possuem bons equivalentes gerenciados, como estruturas de tamanho variável, *s nulos, uniões sobrepostas etc.

As BCLs (Base Class Libraries) do .NET Framework contêm muitos exemplos de APIs que são realmente apenas wrappers densos em torno de grandes quantidades de declarações de P/Invoke. Praticamente toda funcionalidade do .NET Framework que encapsula APIs não gerenciadas do Windows é criada usando a P/Invoke. Na verdade, até mesmo o Windows Forms é baseado quase que totalmente na ComCtl32.dll nativa usando P/Invoke.

Existem pouquíssimos recursos valiosos que podem facilitar significativamente o uso de P/Invoke. O primeiro deles, o site pinvoke.net, tem um wiki, originalmente configurado por Adam Nathan, da equipe de interoperabilidade CLR, com inúmeras contribuições de assinaturas de usuários para uma infinidade de APIs comuns do Windows.

Também há um suplemento muito útil do Visual Studio que facilita a consulta no site pinvoke.net. Para APIs não incluídas no pinvoke.net, sejam elas das suas bibliotecas ou das bibliotecas de terceiros, a equipe de interoperabilidade lançou uma ferramenta geradora de assinaturas P/Invoke chamada Assistente de Interoperabilidade de P/Invoke, que automaticamente cria assinaturas para APIs nativas com base em um arquivo de cabeçalho. As capturas de tela que seguem mostram a ferramenta em ação.

fig01.gif

Criando assinaturas no Assistente de Interoperabilidade de P/Invoke

Tecnologias de interoperabilidade: interoperabilidade COM

A interoperabilidade COM permite consumir interfaces COM de código gerenciado ou expor APIs gerenciadas como interfaces COM. É possível usar a ferramenta TlbImp para gerar uma biblioteca gerenciada que exponha interfaces gerenciadas para conversar com um determinado tlb COM. E a ferramenta TlbExp executa a tarefa oposta, gerando um tlb COM com interfaces que correspondem aos tipos ComVisible em um assembly gerenciado.

A interoperabilidade COM pode ser uma ótima solução para você caso já esteja usando COM no aplicativo ou como modelo de extensibilidade. Também é a forma mais fácil de manter total fidelidade da semântica COM entre código gerenciado e nativo. Especificamente, a interoperabilidade COM é uma excelente opção se você está interoperando com um componente baseado no Visual Basic 6.0, uma vez que o CLR segue basicamente as mesmas regras de COM que o Visual Basic 6.0.

A interoperabilidade COM é menos útil se o aplicativo ainda não usa COM internamente ou se você não precisa de total fidelidade da semântica COM e o desempenho não é aceitável para o aplicativo.

O Microsoft Office é o exemplo mais proeminente de aplicativo que usa a interoperabilidade COM como ponte entre código gerenciado e nativo. O Office foi um excelente candidato para a interoperabilidade COM, porque há muito tempo utiliza COM como o seu mecanismo de extensibilidade e normalmente era mais usado do que o Visual Basic for Applications (VBA) ou o Visual Basic 6.0.

Originalmente, o Office contava inteiramente com TlbImp e o assembly de interoperabilidade estreito como objeto de modelo gerenciado. No entanto, com o passar do tempo, o produto Visual Studio Tools for Office (VSTO) foi incorporado ao Visual Studio, oferecendo um modelo de desenvolvimento cada vez mais rico que reuniu muitos dos princípios descritos nesta coluna. Quando usamos o VSTO hoje, às vezes é tão fácil esquecer que a interoperabilidade COM continua sendo a base do VSTO quanto é esquecer que P/Invoke constitui a base de grande parte das BCLs.

Tecnologias de interoperabilidade: C++/CLI

A C++/CLI serve como ponte entre o mundo nativo e o gerenciado e permite compilar código C++ gerenciado e nativo no mesmo assembly (e até na mesma classe) e fazer chamadas C++ padrão entre as duas partes do assembly. Quando usa C++/CLI, você escolhe qual parte do assembly deve ser de código gerenciado e qual deve ser de código nativo. O assembly resultante é uma combinação de MSIL (Microsoft Intermediate Language, encontrada em todos os assemblies gerenciados) e código de assembly nativo. A C++/CLI é uma tecnologia de interoperabilidade muito poderosa que lhe dá controle total sobre o limite de interoperabilidade. O aspecto negativo é que ela obriga você a assumir o controle quase que total do limite.

A C++/CLI poderá ser uma boa ponte se a verificação de tipo estático for necessária, se o desempenho estrito for uma exigência e se você precisar de uma finalização mais previsível. Caso P/Invoke ou a interoperabilidade COM atenda às suas necessidades, no geral elas são mais fáceis de usar, principalmente se os desenvolvedores não estão familiarizados com a linguagem.

Existem algumas coisas que você deve ter em mente ao considerar o uso da C++/CLI. A primeira é que, se você pretende usar a C++/CLI para oferecer uma versão mais rápida da interoperabilidade COM, esta é mais lenta do que a C++/CLI porque executa muitas tarefas em seu nome. Se você só usa COM vagamente no aplicativo e não precisa de total fidelidade à interoperabilidade COM, esta é uma boa troca.

Porém, se você usa uma grande parte da especificação COM, provavelmente achará que, depois de adicionar as partes necessárias da semântica COM de volta à solução C++/CLI, terá trabalhado muito e obterá desempenho não superior ao oferecido pela interoperabilidade COM. Várias equipes da Microsoft fizeram esse caminho só para perceber isso e voltar à interoperabilidade COM.

A segunda consideração importante sobre o uso da C++/CLI é que ela só deve funcionar como uma ponte entre os mundos gerenciado e nativo e não visa ser uma tecnologia que você usa para criar a maior parte do seu aplicativo. Certamente é possível fazê-lo, mas você perceberá que a produtividade dos desenvolvedores é bem menor do que em um ambiente C++ ou C#/Visual Basic puro e que a inicialização do aplicativo fica mais lenta. Assim, quando você usar a C++/CLI, compile apenas os arquivos de que precisa com a opção /clr e use uma combinação de assemblies gerenciados puros ou nativos puros para criar a funcionalidade de base do seu aplicativo.

Considerações para a sua arquitetura de interoperabilidade

Depois de você ter optado por usar a interoperabilidade no seu aplicativo e escolhido a tecnologia desejada, existem algumas considerações de alto nível a serem feitas sobre a arquitetura da solução, o que inclui o projeto de API e a experiência dos desenvolvedores em codificar no limite de interoperabilidade. Também leve em conta o local onde serão colocadas as transições de código nativo para gerenciado e o impacto que isso poderá causar no desempenho do aplicativo. Por último, você precisa considerar o gerenciamento da vida útil e se precisa fazer algo para preencher a lacuna entre o mundo coletado pelo lixo do ambiente gerenciado e o gerenciamento da vida útil manual/determinístico do mundo nativo.

Projeto de API e a experiência do desenvolvedor

Quando você pensa no projeto de API, há várias perguntas que deve fazer a si mesmo: quem irá codificar na minha camada de interoperabilidade e devo otimizar para melhorar a experiência do usuário ou para minimizar o custo de estabelecer o limite? Os desenvolvedores estão codificando neste limite e são os mesmos que estão escrevendo o código nativo? Existem outros desenvolvedores na sua empresa? Eles são desenvolvedores de terceiros que estão estendendo o seu aplicativo ou utilizando-o como serviço? Qual é o nível de sofisticação? Eles estão familiarizados com paradigmas nativos ou só ficam satisfeitos quando escrevem código gerenciado?

Suas respostas a essas perguntas ajudarão a determinar aonde você chegará no continuum entre um wrapper muito estreito no código nativo e um modelo de objeto gerenciado rico que secretamente usa o código nativo. Com um wrapper estreito, todos os paradigmas nativos se esvairão por completo, e os desenvolvedores ficarão bastante cientes do limite e do fato de que estão codificando em uma API nativa. Com um wrapper mais denso, você consegue ocultar quase que completamente o fato de que o código nativo está em cena — as APIs do sistema de arquivo na BCL são um ótimo exemplo de camada de interoperabilidade muito densa que oferece um modelo de objeto gerenciado de primeira classe.

Desempenho e a localização do limite de interoperabilidade

Antes que você perca muito tempo otimizando seu aplicativo, é importante determinar se existe ou não um problema de desempenho de interoperabilidade. Muitos aplicativos utilizam a interoperabilidade em seções em que o desempenho é fundamental e por isso devem prestar bastante atenção a este aspecto. Contudo, muitos outros vão usar a interoperabilidade em resposta a cliques de mouse do usuário e não verão dezenas, centenas ou até mesmo milhares de transições de interoperabilidade causando atrasos para seus usuários. Isso posto, quando você observa o desempenho da solução de interoperabilidade, deve ter em mente dois objetivos: reduzir o número de transições de interoperabilidade feitas e diminuir o volume de dados passados em cada transição.

Uma dada transição de interoperabilidade com um determinado volume de dados transitando entre o mundo gerenciado e o nativo basicamente terá um custo fixo. Esse custo fixo será diferente conforme sua opção de tecnologia de interoperabilidade; porém, se você fizer essa escolha porque precisa dos recursos dessa tecnologia, não poderá mudá-la. Isso significa que o seu objetivo deve ser reduzir a falação sobre o limite e, depois, o volume de dados passados.

Como fazer isso depende em grande parte do seu aplicativo. Mas uma estratégia comum e aceitável com a qual muitos têm tido sucesso é mudar o limite de isolamento criando um pouco de código no lado do limite que definiu a interface ocupada e carregada de dados. A idéia básica é criar uma camada de abstração que reúna as chamadas na interface muito ocupada ou, ainda melhor, mover a parte da lógica do aplicativo que precisa interagir com essa API pelo limite e passar somente as entradas e os resultados pelo limite.

Gerenciamento da vida útil

As diferenças no gerenciamento da vida útil entre o mundo gerenciado e o nativo muitas vezes é um dos maiores desafios enfrentados pelos clientes de interoperabilidade. A diferença fundamental entre o sistema baseado em coleta de lixo no .NET Framework e o sistema manual e determinístico no mundo nativo com freqüência pode se manifestar de formas surpreendentes que dificultam o diagnóstico.

O primeiro problema que você poderá observar em uma solução de interoperabilidade é o longo período que alguns objetos gerenciados aguardam para usar seus recursos nativos, mesmo depois que o mundo gerenciado terminou de usá-los. Com freqüência isso causa problemas quando o recurso nativo é muito escasso e depende de ser liberado assim que os chamadores terminam de usá-lo (um ótimo exemplo são as conexões de banco de dados).

Quando esses recursos não são escassos, você pode simplesmente contar que o coletor de lixo chamando o finalizador de um objeto e deixando que este libere os recursos nativos (implícita ou explicitamente). Quando os recursos são escassos, o padrão de descarte gerenciado pode ser muito útil. Em vez de expor objetos nativos diretamente ao código gerenciado, você deve colocar pelo menos um wrapper estreito ao redor deles que implemente IDisposable e siga o modelo de descarte padrão. Dessa maneira, se você considera o esgotamento de recursos um problema, pode descartar esses objetos explicitamente no seu código gerenciado e liberar recursos assim que terminar de usá-los.

O segundo problema do gerenciamento da vida útil que costuma afetar os aplicativos é um que os desenvolvedores muitas vezes entendem como sendo uma coleta de lixo inflexível: o uso da memória continua aumentando, mas por algum motivo o coletor de lixo está sendo executado raras vezes e os objetos permanecem ativos. Com freqüência eles continuarão adicionando chamadas a GC.Collect para forçar o problema.

A raiz desse problema é que normalmente há uma grande quantidade de objetos gerenciados muito pequenos que preservam, e mantêm ativas, estruturas de dados nativas muito grandes. O que acontece é que o coletor de lixo está se auto-ajustando e tenta evitar perder tempo fazendo coletas desnecessárias ou inúteis. E, além de considerar a pressão de memória atual do processo, ele verifica quanto de memória cada coleta de lixo libera quando decide fazer outra.

No entanto, quando o coletor de lixo é executado neste cenário, ele verifica que cada coleta só libera uma pequena quantidade de memória (lembre-se de que ele só sabe quanta memória gerenciada foi liberada) e não percebe que liberar esses objetos pequenos pode reduzir a pressão geral consideravelmente. Isso leva a uma situação em que ocorrem cada vez menos coletas, mesmo que o uso da memória continue crescendo.

A solução para esse problema é dar dicas para o coletor de lixo sobre o custo real da memória de cada um desses pequenos wrappers gerenciados em relação aos recursos nativos. Adicionamos um par de APIs ao .NET Framework 2.0 que permite fazer exatamente isso. Você pode usar o mesmo tipo de wrappers que utilizou para adicionar os padrões de descarte a recursos escassos, mas deve adaptá-los para dar dicas ao coletor de lixo em vez de você mesmo ter de liberar os recursos explicitamente.

No construtor deste objeto, basta chamar o método GC.AddMemoryPressure e passar o custo aproximado na memória nativa do objeto nativo. Em seguida, você pode chamar GC.RemoveMemoryPressure no método do finalizador do objeto. Esse par de chamadas ajudará o coletor de lixo a entender o verdadeiro custo desses objetos e a memória real que é liberada quando eles são liberados. Observe que é importante balancear perfeitamente as chamadas feitas a Add/RemoveMemoryPressure.

A terceira incoerência comum no gerenciamento da vida útil entre os mundos gerenciado e nativo não tem muito a ver com o gerenciamento de recursos ou objetos individual, mas sim com assemblies ou bibliotecas inteiras. As bibliotecas nativas podem ser facilmente descarregadas quando um aplicativo termina de usá-las, mas as gerenciadas não podem descarregar a si mesmas. Em vez disso, o CLR tem unidades de isolamento chamadas AppDomains que podem ser descarregadas individualmente e limparão todos os assemblies, objetos e até mesmo threads em execução nesse domínio quando descarregadas. Se você estiver compilando um aplicativo nativo e tem o hábito de descarregar seus suplementos quando termina de usá-los, perceberá que usar diferentes AppDomains para cada um dos suplementos gerenciados lhe confere a mesma flexibilidade que você tinha ao descarregar bibliotecas nativas individuais.

Envie perguntas e comentários para clrinout@microsoft.com.

No momento, Jesse Kaplan é gerente de programas de interoperabilidade de código gerenciado/nativo na equipe de CLR da Microsoft. Suas responsabilidades anteriores incluem compatibilidade e extensibilidade.