Categoria: C++

Linguagens compiladas, interpretadas e byte-code

Introdução

Recentemente, comecei a aprender Python. Tudo começou num bate-papo no café do TDC 2013, com o @juanplopes (thanks!). Estávamos discutindo sobre linguagens, ele mencionou o Python e eu imediatamente falei sobre minha “birra” com linguagens interpretadas. Então o Juan comentou de algumas alternativas de JIT-Compiler para o Python.

Esse post nasceu para falar de duas coisas: 1) Minha birra, 2) JIT-Compiler para o Python.

Um resuminho sobre linguagens compiladas, interpretadas e byte-code

Não vou me aprofundar nesse tema, que dá muita discussão, mas a idéia é só fazer uma diferenciação básica entre os 3 mundos.

Linguagens interpretadas em geral carregam todo o seu interpretador na memória, e conforme vão executando, traduzem as instruções de sua linguagem para instruções em linguagem de máquina. Um exemplo disso, é o velho VB (falo do VB6 ou ASP clássico, não do VB.NET). Quando você faz uma página ASP, simplesmente coloca ela no servidor e ela “funciona”. Já o VB clássico, é um pouco diferente. Como ele produz um “EXE” ele faz você pensar que é compilado, mas na verdade é o interpretador do VB, com teu código praticamente todo embutido como resource dentro dele. O antigo Clipper era assim também. Já o dBase (!) era um interpretador puro mesmo.

Linguagens compiladas, na sua etapa de compilação, traduzem o código fonte em linguagem de máquina. O binário (linguagem de máquina) não passa por toda a etapa de interpretação em tempo de execução, o que faz com que a execução seja muito mais rápida. O C, C++, Object Pascal (Delphi) são assim. Em geral, linguagens compiladas também são “não gerenciadas”, o que significa na prática que você deve controlar a gestão da sua memória por conta própria, o que traz muito controle e muito trabalho para o desenvolvedor.

A idéia do byte-code, veio para achar um meio-termo entre os dois mundos. O C#, VB.NET e Java são assim. Em tempo de compilação, você traduz da linguagem nativa (C#, Java) para uma linguagem intermediária. Assim são os assemblies .NET e os .class no java. Em tempo de execução, o framework traduz as instruções de byte-code para linguagem de máquina, porém, faz isso uma vez só. Esse é o processo do “JIT-Compiler” (just-in-time compiler). Em geral, a primeira execução é lenta pois passa pela compilação, mas nas etapas subsequentes, o tempo é mais ou menos parecido com uma linguagem compilada. Linguagens com byte-code em geral são “gerenciadas”, ou seja, possuem um garbage collector e cuidam de sua própria gestão de memória, o que por um lado diminui o trabalho do desenvolvedor, mas também diminui o controle do desenvolvedor sobre a gestão da memória (característica desejável para alguns tipos de aplicação).

Qual o problema com linguagens interpretadas?

O desempenho. Desconheço casos práticos em que linguagens interpretadas conseguem ser mais rápidas que compiladas e byte-code. O overhead de interpretar as instruções e traduzir em tempo de execução é muito grande.

Comparando entre as compiladas e byte-code, a discussão é mais complexa. O pessoal de compiladas (C++) costuma justificar que em casos extremos consegue fazer otimizações que os byte-codes não conseguem, e o pessoal de byte-code justifica que os ambientes “aprendem” conforme a execução e podem melhorar conforme o processo roda mais.

Eu percebo que na prática, JIT é mais lento que linguagem nativa no caso médio, porém, o custo vem no trabalho que dá fazer gestão de memória em linguagens compiladas. Acho que ambas tem suas aplicações.

Um micro-benchmark

Micro-benchmarks são péssimos e legais ao mesmo tempo. Péssimos porque não dá pra avaliar o desempenho de uma linguagem/ambiente num único SO, com um único exemplo bobo. Legais porque ajudam a ter uma idéia.

Resolvi fazer um exemplo muito simples. Um bubble sort (algoritmo popularmente conhecido por ser uma porcaria e gastar muitos recursos computacionais) e fazer o coitado ordenar uma matriz que está em ordem decrescente (pior caso). Assim ele gera O(n^2) comparações. Implementei o mesmo algoritmo em Python, C# e C++.

As implementações seguem:

C#


    public static class BubbleSort2
    {
        public static void Sort<T>(IList<T> list) where T: IComparable
        {
            bool swap = true;
            while (swap)
            {
                swap = false;
                for (int i = 1; i < list.Count; i++)
                {
                    if (list[i].CompareTo(list[i - 1]) < 0)
                    {
                        T tmp = list[i];
                        list[i] = list[i - 1];
                        list[i - 1] = tmp;
                        swap = true;
                    }
                }
            }
        }
    }

        [TestMethod]
        public void TestBigBubbleSort()
        {
            List<int> l = new List<int>(20000);
            for (int i = 20000; i >= 0; i--)
                l.Add(i);
            BubbleSort2.Sort(l);
        }

C++

#include <vector>

inline void Swap(std::vector<int> *l, int p1, int p2){
	int tmp = l->at(p1);
	l->at(p1) = l->at(p2);
	l->at(p2) = tmp;
}

void Sort(std::vector<int> *l){
	bool switched = true;
	while (switched){
		switched = false;
		for (int i = 1; i < l->size() - 1; i++){
			if (l->at(i) < l->at(i - 1)){
				Swap(l, i, i - 1);
				switched = true;
			}
		}
	}
}

namespace AlgorithmsTests
{		
	TEST_CLASS(UnitTest1)
	{
	public:
		
		TEST_METHOD(TestBigBubbleSort)
		{
			std::vector<int> l = std::vector<int>();			
			for (int i = 20000; i >= 0; i--)
				l.push_back(i);
			Sort(&l);

		}

	};
}

Python


def swap(list, p1, p2):
    tmp = list[p1]
    list[p1] = list[p2]
    list[p2] = tmp       

def bubble_sort(list):
    switched = True
    while switched :
        switched = False
        for i in range(len(list) - 1) :
            if i + 1 > len(list) - 1 :
                break
            if list[i] > list[i + 1] :
                swap(list, i, i + 1)
                switched = True
                
            
import unittest
from algorithms.core import bubble_sort

class BubbleSortTests(unittest.TestCase):
        
    def test_big_bubble_sort(self):
        l = [x for x in range(20000, 0, -1)]
        bubble_sort.bubble_sort(l)
    
if __name__ == '__main__':
    unittest.main()
       

Tempos de execução

Linguagem Tempo (ms)
C# 7.000
C++ 874
Python 109.199

Entenderam a razão da minha bronca com linguagens interpretadas?

Curiosidade inútil: Compilando em Debug, o C# demora 10s e o C++ em 1 minuto!

PyPy para o resgate

O PyPy é um JIT-Compiler para o Python. Neste ambiente, temos o seguinte resultado:

Linguagem Tempo (ms)
C# 7.000
C++ 874
Python (CPython) 109.199
Python (PyPy) 4.393

Como vemos no exemplo, apresenta um tempo de execução melhor que o C#. Interessante, né?

Resta saber se é um ambiente de execução maduro, mas é o suficiente para despertar meu interesse pela linguagem. Breve poderemos ter mais posts por aqui.

Performance

Objetivo

Apesar desse ser um assunto muito básico pra quem já é experiente na área de desenvolvimento e está sendo tratado aqui de uma forma bem superficial, as vezes conversando com pessoas não tão experientes na área percebo que este conceito de performance gera bastante confusão.

Essa foi minha motivação para escrever esse post, ou seja, tentar entender melhor como formatar requisitos de performance e analisar problemas.

O que é performance?

Um bom post tem que começar com uma citação de uma frase famosa e de efeito. Lá vai:

“Premature optimization is the root of all evil”Donald Knuth.

Para não tirar o texto do contexto, vai a íntegra: “Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs, and these attempts at efficiency actually have a strong negative impact when debugging and maintenance are considered. We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.”

Fazendo uma tradução livre: “Otimização prematura é a origem de todo o mal”. O que ele quer dizer aqui é que programadores perdem um tempo enorme se preocupando com a velocidade de partes não-críticas dos programas, gerando um impacto negativo na manutenção e depuração. Vale mais apena focar nos outros 3%.

Eu gosto dessa corrente de pensamento porque vai mais de encontro com o que enfrentamos no dia-a-dia.

Mas performance é só velocidade? Olha o que a Wikipedia nos diz: Computer Performance.

Aqui percebemos que existe uma série de métricas de performance, diversas formas de estabelecer o que é importante ser otimizado na sua aplicação.

Onde eu quero chegar é que é importante estabelecer critérios na hora de otimizar. O que é uma performance aceitável? Um usuário não demorar mais de 5s para ter a resposta de uma página? O processo de fechamento do mês levar 1h e não 1 dia para ser concluído?

Geralmente chegamos naquelas premissas: “O mais rápido o possível”. Isso não tem fim. Sempre tem o que melhorar, a um preço cada vez maior. Se você ouvir uma afirmação dessas, é fácil contra argumentar com: Vamos construir o maior cluster de computadores do mundo e então conseguiremos a melhor performance do mundo, aí vai ouvir um contra-argumento: “Mas não pode aumentar o custo”. Então a brincadeira começa.

Quando falo de critérios, é importante saber o tipo de aplicação que estamos falando. Isso é determinante para estabelecer os critérios. Escrevi com foco em aplicações corporativas. Em software embarcado por exemplo, existem menos variáveis, porém a disponbilidade de CPU e memória é muito pequena. É quase uma arte fazer o que esse pessoal faz com tão pouco hardware. Mobile também tem uma característica muito parecida.

Equilíbrio: A chave do sucesso

Não dá para conseguir ganhar sempre quando estamos desenvolvendo software. Se quer produtividade durante o desenvolvimento, é possível que a performance tenha que ser sacrificada. Se quer otimizar um determinado pedaço da aplicação, é provável que aquele código fique com uma manutenção mais complexa que outros. Não dá pra ganhar todas.

Muitas vezes eu passei por discussões de design em que o argumento performance é jogado na mesa. Eu adoro fazer sistemas rápidos e eficientes, mas também gosto de sistemas que tem um longo ciclo de vida, que suporte manutenção e extensão sem ficar impossível de gerenciar. Por isso, é importante ter os critérios, definir claramente os requisitos não funcionais para conseguir um bom equilíbrio entre performance, manutenção, custo, time-to-market ou quaisquer outros fatores que sejam relevantes para o negócio (ver A arquitetura perfeita).

Como otimizar uma aplicação?

Partindo mais para o lado prático, o que exatamente fazemos para melhorar o desempenho?

A aplicação perde desempenho quando faz mal uso de recursos. É igual as contas do governo ou a nossa conta corrente. Se não administramos bem nossos recursos, gastamos muito sem desfrutar muito destes gastos. A máquina é igual.

Mas quais recursos a máquina tem?

A resposta é: vários. Pensando em aplicações enterprise orientadas a banco de dados, temos como principais fatores:

  • Banco de dados
  • Rede
  • Memória
  • CPU
  • Disco rígido

Esses fatores na grande maioria das vezes são os principais vilões, a maioria das vezes nessa ordem.

Como investigar problemas de performance?

Troubleshooting

Geralmente quando recebemos uma reclamação sobre performance, recebemos uma solicitação do tipo: “O usuário manda visualizar o pedido e demora demais”. Como vamos identificar onde é o problema?

Como qualquer técnica de troubleshooting, é importante isolar variáveis. Coloquei em negrito porque essa regra é de ouro. Se você tem um problema, vá removendo todas as variáveis possíveis até encontrar aquela que é a causa. Isso vale não só para performance, mas para qualquer outro problema.

Esse procedimento utiliza quais recursos? Praticamente todos. Vamos usar uma aplicação Web como exemplo: tem a velocidade da internet (largura de banda e latência), o tempo de processamento do request pelo servidor web, o tempo de acesso do servidor até o banco de dados (LAN) e a volta.

Acessando a mesma funcionalidade de uma rede local já elimina a primeira variável. Se continua tendo o problema, a rede não é o problema.

Em .NET e mesmo em outras plataformas, existem ferramentas de profiling. Em .NET tem o Red Gate ANTS, ou mesmo o JetBrains dotTrace que são muito boas, em C/C++ tem o profiler da Microsoft.

Em geral essas ferramentas dizem em qual lugar do código, a CPU ficou mais tempo parada. Em geral isso denuncia não só problemas de CPU como problemas de espera por outros recursos (Ex.: acesso a um arquivo mapeado na rede, acesso ao banco de dados).

Na inexistência desse tipo de ferramenta, é só tirar aleatoriamente chamadas do banco de dados e você começa a perceber quais delas causam a lentidão. Se a lentidão ainda está lá, não é problema nestas variáveis.

Essa dinâmica ajuda facilmente a encontrar onde é o problema (não a solução).

Banco de dados

Quando identifica-se o problema no banco de dados (quase sempre é nele!) caem-se em outros casos. Plano de execução da query ruim, varrendo muitos registros para conseguir retornar o resultado é o mais comum. Nesse caso é importante corrigir a consulta, as vezes criar índices, ou mesmo rever conceitos da modelagem do banco.

O segundo grande vilão é a quantidade de informações retornadas. Se o banco devolve muitos registros (Ex.: 500.000 registros), essa informação vai trafegar pela rede entre o banco de dados e o servidor de aplicação (client/server ou o Web Server). Isso é muitas vezes causa de lentidão.

O terceiro caso é que o banco de dados, sendo um servidor separado também tem CPU, Rede, Memória e disco. Rodar o task manager do Windows ou outra ferramenta de monitoração (como NAGIOS por exemplo), ajuda a saber se o servidor está sofrendo de algum desses recursos.

As vezes o plano de execução da query está bom, mas o servidor de banco de dados tem um disco muito lento, ou um storage muito lento que não dá vazão para as solicitações, lembrando que muitas vezes o banco de dados atende várias aplicações diferentes e é o ponto mais difícil de dar escalabilidade. É difícil e caro montar clusters para bancos de dados.

Outro caso muito comum é quando a lentidão é intermitente. Isso é uma evidência que pode existir problema de concorrência. Verifique se existem transações longas e muita disputa por recursos do banco de dados. Cada banco de dados tem seus meios para investigar esse tipo de problema.

Alguns dos tópicos que eu citei aqui muitas vezes estão nas responsabilidades do DBA.

Rede

Já falei um pouco deste caso especificamente para banco de dados, mas para outros tipos de aplicação como aplicações Web, aplicações “thin client” ou RIA (que rodam em redes remotas com acesso sobre HTTP), existem outras variáveis.

Uma delas é a latência da rede. Redes remotas não se dão bem com grandes quantidades de pequenso requests. A internet é uma delas. Nesse caso, deve-se procurar os famosos “Coarse Grained Services” como o Fowler gosta de chamá-los.

Nos casos em que eles já existem, observar o protocolo usado. Informações representadas em HTML, XML são muito pesadas. Existem muitas tags para poucas informações. ViewState para aplicações ASP.NET também é um vilão. É bem pesado.

Para identificar esses casos, é só mudar para uma rede local e ver se a performance muda drasticamente, acessando o mesmo recurso. Outra forma é colocando um sniffer na rede e analisando os pacotes. Em aplicações Web o plugin Live HTTP Headers do Firefox também é um bom amigo.

Não é raro pegar casos de aplicações ASP.NET WebForms com páginas na ordem de megabytes de peso.

Memória

Em geral, um dos problemas de ter um garbage collector no .NET é que muitas pessoas assumiram que com essa facilidade não precisam mais se preocupar com a memória. Isso é mentira. Dá pra fazer memory leaks em .NET como em qualquer outra linguagem (ver Garbage Collector e IDisposable). Em linguagens não gerenciadas é mais fácil ainda criar memory leaks.

Executando o task manager é possível analisar o quanto a aplicação consome de memória. O Red Gate Memory Profiler também é muito bom para caçar memory leaks em .NET.

O problema é que quando muita memória começa a ser consumida, o sistema operacional começa a paginar a memória em disco, consequentemente a máquina vai ficando cada vez mais lenta.

Neste caso em ambiente .NET o ideal é um memory profiler mesmo. Só assim é possível saber onde estão os gargalos e os leaks.

CPU

Também é fácil identificar quando o problema é CPU. Qualquer task manager denuncia facilmente o problema. O difícil é corrigir. Quando caímos nestes problemas de CPU é porque a aplicação possui algoritmos ruins para realizar suas tarefas.

Nesse caso, a otimização é baseada na complexidade dos algoritmos. Trocar algoritmos O(n) por algoritmos de complexidade inferior, ajuda bastante. Em geral algoritmos de ordem quadrática são os grandes vilões (loops dentro de loops).

Pra quem não tem a menor idéia do que eu estou falando, procurem um livro sobre isso. Pode realmente mudar sua vida como cientista da computação. A obra de referência neste campo é o The Art of Computer Programming do Knuth, mas confesso que é um livro um tanto quanto difícil. A parte matemática dele é insana. A menos que você vá seguir uma carreira acadêmica e pretenda escrever provas matemáticas para seus algoritmos, sugiro uma leitura mais fácil.

Eu gostei muito do livro do Steve Skiena Algorithm Design Manual. Lembrando que quase nunca você vai escrever todas estas estruturas de dados, mas saber como elas funcionam ajuda muito na hora de criar um algoritmo eficiente.

Por exemplo, imagine que você tem uma lista (List) com 100.000 registros. Toda vez que você procura um produto por código, você faz um foreach nesta lista (vou sumir com o LINQ daqui pra simplificar o exemplo) para encontrar um produto. Por estar usando uma lista, automaticamente, vc está usando um algoritmo O(n) (O livro te explica o por que!).

Uma boa otimização nesse caso, seria usar um Dictionary (sendo a string o código do produto). Para este caso, cada busca é O(log n), muito melhor.

O livro do Skiena inclusive dá um “mapa” pra saber a partir de qual massa de dados as complexidades passam a ser impossíveis. Vou repetir ele aqui:

n f(n) log n n n log n n2 2n
10 0,003 µs 0,01 µs 0,033 µs 0,1 µs 1 µs
20 0,004 µs 0,02 µs 0,086 µs 0,4 µs 1 ms
30 0,005 µs 0,03 µs 0,147 µs 0,9 µs 1 s
40 0,005 µs 0,04 µs 0,213 µs 1,6 µs 18,3 min
50 0,006 µs 0,05 µs 0,282 µs 2,5 µs 13 dias
100 0,007 µs 0,1 µs 0,644 µs 10 µs 4 x 1013 anos
1.000 0,010 µs 1 µs 9,966 µs 1 ms
10.000 0,013 µs 10 µs 130 µs 100 ms
100.000 0,017 µs 10 µs 130 µs 100 ms
1.000.000 0,020 µs 1 ms 19,93 ms 16,7 min
10.000.000 0,023 µs 0,01 s 0,23 s 1,16 dias
100.000.000 0,027 µs 0,10 s 2,26 s 115,7 dias
1.000.000.000 0.030 µs 1 s 29.9 s 31.7 anos

É óbvio que o tempo exato para execução do algoritmo depende do algoritmo e da máquina, mas essa tabela nos mostra a ordem de grandeza de uma forma mais simples. Por exemplo, se eu pegar um bubble sort que é de complexidade O(n2) e compará-lo com um quicksort que é O(n log n) (ambos comparados em casos MÉDIOS), para uma massa de dados de 1 bilhão, o primeiro demora 31,7 anos para executar enquanto o segundo 29,9 segundos.

Disco rígido

Esse caso é bem chatinho de monitorar. Mas a se o seu código utiliza algum FileStream dentro dele ou salva alguma coisa em disco por outro método, já dá uma pista. A vantagem é que este caso é bem mais difícil de ocorrer.

A idéia é encontrar um bom task manager para identificar esse tipo de problema. Lembrando que disco rígido é lento por natureza (é mecânico).

As vezes cachear informações em memória e jogar para o disco somente quando elas atingem um determinado acúmulo é melhor do que fazer muitas gravações pontuais.

Outros casos

Esses casos são mais difícieis de acontecerem em aplicações comerciais. Acontecem muito em aplicações mais de baixo nível, mas vale a pena relatar.

Um deles é o problema de concorrência dentro da máquina. Muitas vezes usa-se threads sem muito critério (afinal, thread é mais rápido, né?) e as vezes ocorrem “corridas de saco” um monte de threads disputando a CPU e brigando por recursos, esperando uma série de locks, semáforos, mutexes.

Thread tem um custo alto para o sistema operacional. Toda vez que ele troca de uma thread para outra, ocorre uma troca de contexto (mudam-se vários registradores na CPU pra saber qual a próxima coisa que ela deve fazer). Você pode jogar 500 threads na aplicação, mas só vai rodar em paralelo a quantidade de núcleos (cores) que a sua CPU tem. Não estou dizendo para não usar threads nunca, estou dizendo para usar com critério e sabendo muito bem o que está fazendo.

Outras causas de lentidão também podem ser recursos como GDI ou outros kernel objects do Windows. Vazamento desse tipo de recurso é bem chatinho de localizar. Dá para monitorar isso pelo task manager do windows (as colunas vem por default ocultas).

Outros casos bem difíceis de depurar são combinações de dois recursos diferentes que “escondem” o real problema das nossas ferramentas. Um bom exemplo disso são grandes loops com pequenos acessos a banco. O custo de CPU não aparece porque ela não chega a ficar com alto consumo já que a cada passada do loop, a CPU para para esperar o resultado do servidor. A banda não aparece porque são muitos requests parecidos e pequenos. E o tempo para execução da consulta não aparece porque é uma pequena consulta executada uma grande quantidade de vezes. Talvez o único lugar que isso apareça é no profiler. Felizmente este caso é mais exceção do que regra.

Conclusão

A idéia desse post é dar uma dica de como o mal uso de diferentes recursos computacionais pode impactar na performance e sugerir algumas técnicas simples de análise destes problemas.

Lembre-se sempre da otimização prematura e tenha cuidado em ficar se preocupando demais com otimizar o que é desnecessário. Por isso, estabeleça “alvos” sempre que começar uma otimização.

Outra dica é que por melhor que seja o seu código e o seu design, sempre tem o que melhorar em relação a performance. Busque soluções equilibradas.

Acessando DLL’s não gerenciadas em .Net – Parte 2

Objetivo

Na primeira parte (Acessando DLL’s Não Gerenciadas No .Net – Parte 1), falamos um pouquinho sobre como acessar DLL’s não gerenciadas em .Net.

Vamos falar um pouco mais agora de como carregar DLL’s dinamicamente e para concluir essa idéia, vamos precisar entender também como compatibilizar delegates e function pointers.

Carregando a DLL Dinamicamente

O carregamento dinâmico de DLL’s pelo Windows pode ser realizado através das funções LoadLibrary (LoadLibrary Function) e FreeLibrary (FreeLibrary Function).

Quando se executa LoadLibrary, deve ser passado o caminho da DLL a ser carregada. A função retorna um handle da DLL. O handle pode ser entendido como um identificador. Sempre que tivermos que fazer algo com essa DLL, temos que passar de volta o handle.

A FreeLibrary descarrega a DLL. Passamos o handle e ela é liberada da memória.

Vimos na primeira parte, que mesmo quando anotamos o método no .Net com [DllImport], o carregamento da DLL acontece de forma dinâmica, ou seja, ela só é carregada quando o método é executado. Então que vantagem temos de usar esse método?

A única vantagem que eu vejo é escolher o caminho da DLL. Usando o [DLLImport] temos que inevitavelmente seguir a regra de carregamento da DLL (diretório da aplicação, system32, path). Nesse caso, podemos carregar de qualquer lugar.

De qualquer forma, o trabalho vale a pena, já que vai nos ajudar a compatibilizar function pointers em C++.

Declarando as funções da API no .Net

As funções que vamos precisar para carregar a DLL dinamicamente são: LoadLibrary, FreeLibrary e GetProcAddress. A definição das chamadas dela na API do Windows são:


BOOL WINAPI FreeLibrary(
  __in  HMODULE hModule
);

HMODULE WINAPI LoadLibrary(
  __in  LPCTSTR lpFileName
);

FARPROC WINAPI GetProcAddress(
  __in  HMODULE hModule,
  __in  LPCSTR lpProcName
);

Vamos discutir um pouco a tipagem, para entendermos a forma que essas funções serão representadas no .Net.

O tipo BOOL (tudo maiúsculo) é definido pela API do Windows. A idéia é armazenar um true ou false, mas o tipo foi redefinido pois dependendo da versão do windows, pode ter uma representação interna diferente.

WINAPI é outra macro que define o calling convention da função (stdcall ou cdecl).

HMODULE é um handle para uma DLL. Internamente o handle é representado por um inteiro, o que facilita bastante nossa vida no C#.

LPCSTR é um ponteiro para uma string terminada em zero. Pode ser de um ou dois bytes dependendo da versão do Windows (na parte um discutimos um pouco isso).

Agora que já sabemos um pouco do tipo, vamos alterar o nosso CppInterop.cs (criado na parte um do tutorial), excluir nossas funções sum, otherSum e concat e adicionar as seguintes funções:

    [DllImport("kernel32.dll", EntryPoint = "LoadLibrary")]
    public static extern int LoadLibrary([MarshalAs(UnmanagedType.LPStr)] string lpLibFileName);

    [DllImport("kernel32.dll", EntryPoint = "GetProcAddress")]
    public static extern IntPtr GetProcAddress(int hModule, [MarshalAs(UnmanagedType.LPStr)] string lpProcName);

    [DllImport("kernel32.dll", EntryPoint = "FreeLibrary")]
    public static extern bool FreeLibrary(int hModule);

Aqui percebemos que o LPCSTR foi compatibilizado para um string (o Platform Invoke resolve isso), os handles para ints, e o BOOL para bool. O Platform Invoke resolve a maioria dos nossos problemas.

Alterando o código C#

Vamos arrastar para o nosso form um OpenDialog (que vai ganhar o nome openDialog1).

Vamos alterar agora o nosso CSharpApp e mudar o código do nosso button1 para:

      if (openFileDialog1.ShowDialog() != DialogResult.OK)
        return;

      int hModule = CppInterop.LoadLibrary(openFileDialog1.FileName);

      if (hModule <= 0)
        throw new Exception("DLL not loaded.");

      MessageBox.Show("DLL loaded");

      CppInterop.FreeLibrary(hModule);

Como percebemos, o código executa o diálogo para procurar um arquivo e você pode escolher qualquer arquivo.

Se escolher um arquivo que não é uma DLL, vai perceber que a função LoadLibrary retorna 0, e uma exceção é disparada.

Se escolher um arquivo DLL válido, ele vai ser carregado, a mensagem exibida e posteriormente descarregado. Se usarmos o Process Explorer para examinar a aplicação, conseguimos ver o carregamento e o descarregamento da DLL.

Chamando métodos através de DLL’s carregadas dinamicamente

Sempre que carregamos a DLL dinamicamente não “conhecemos” as funções da DLL. A única forma de conseguirmos interagir com ela é através da função GetProcAddress, que tem a seguinte chamada na API do Windows:


FARPROC WINAPI GetProcAddress(
  __in  HMODULE hModule,
  __in  LPCSTR lpProcName
);

Essa função recebe como parâmetro o handle da DLL e o nome da função, conforme exportada pela DLL (arquivo .def). O retorno “FARPROC” é um ponteiro para uma função (mais uma macro da API do Windows).

Esse conceito existe nas linguagens não-gerenciadas. Uma vez que você sabe o endereço na memória onde está a função, e redefine ela com o mesmo calling convention e parâmetros, é possível chamá-la.

Para isso, é necessário compatibilizar este function pointer, retornado pela GetProcAddress, com um delegate. Como dissemos anteriormente, o tipo que compatibiliza diretamente com qualquer ponteiro no C# é o IntPtr.

Para isso, vamos definir as mesmas funções da nossa SampleDLL como delegates. São elas:


  public delegate int SumDelegate(int a, int b);

  public delegate void OtherSumDelegate(int a, int b, ref int result);

  public delegate void ConcatDelegate([MarshalAsAttribute(UnmanagedType.LPWStr)]string src1,
      [MarshalAsAttribute(UnmanagedType.LPWStr)]string src2, IntPtr dest, int destSize);

Agora, no nosso código do button1, vamos fazer o seguinte:

      if (openFileDialog1.ShowDialog() != DialogResult.OK)
        return;

      int hModule = CppInterop.LoadLibrary(openFileDialog1.FileName);

      if (hModule <= 0)
        throw new Exception("DLL not loaded.");

      MessageBox.Show("DLL loaded");

      IntPtr sumPtr = CppInterop.GetProcAddress(hModule, "sum");
      SumDelegate sum = (SumDelegate)Marshal.GetDelegateForFunctionPointer(sumPtr, typeof(SumDelegate));
      int res = sum(10, 20);
      MessageBox.Show(res.ToString());

      IntPtr otherSumPtr = CppInterop.GetProcAddress(hModule, "otherSum");
      OtherSumDelegate otherSum = (OtherSumDelegate)Marshal.GetDelegateForFunctionPointer(otherSumPtr, typeof(OtherSumDelegate));
      otherSum(5, 2, ref res);
      MessageBox.Show(res.ToString());

      IntPtr concatPtr = CppInterop.GetProcAddress(hModule, "concat");
      ConcatDelegate concat = (ConcatDelegate)Marshal.GetDelegateForFunctionPointer(concatPtr, typeof(ConcatDelegate));
      IntPtr p = Marshal.AllocHGlobal(100);
      concat("String ", "Concatenation", p, 100);
      MessageBox.Show(Marshal.PtrToStringUni(p));
      Marshal.FreeHGlobal(p);

      CppInterop.FreeLibrary(hModule);

A parte interessante aqui, começa no GetProcAddress. Quando chamamos, retornamos o ponteiro da função numa variável do tipo “IntPtr”.

Depois, quem faz o trabalho sujo é o Marshal.GetDelegateForFunctionPointer. Esse método faz o “cast” do function pointer não gerenciado para um delegate. Uma vez feita a conversão, é só chamar o delegate e estaremos executando o código não gerenciado.

Fizemos isso para nossos 3 delegates.

Callbacks em C++

Ora, se podemos facilmente intercambiar uma função entre ambientes gerenciados e não gerenciados, quer dizer que eu posso passar uma função em C# como parâmetro para uma função em C++?

Sim. É claro que pode. Vamos fazer isso no nosso exemplo.

Primeiro, vamos para o nosso SampleDLL em C++, e adicionamos o seguinte método:

void  keepSayingSomething(void (*callback)(LPTSTR thingToSay)){
	for(int i = 1; i <= 100; i++){
		_TCHAR str3[100];
		_stprintf_s(str3, 100, _TEXT("Saying %i"), i);
		callback(str3);
	}
}

A sintaxe abaixo, equivale à declaração de um function pointer em C++. Leia-se “ponteiro para um método quer retorna void, chama callback e possui um argumento chamado thingToSay do tipo LPTSTR (ponteiro para um array de caracteres, de um ou dois bytes, dependendo do define “UNICODE”. No nosso caso, estamos compilando com UNICODE, logo, 2 bytes).

void (*callback)(LPTSTR thingToSay)

A idéia do método é fazer um loop e chamar 100 vezes o método passado como parâmetro com uma string.

Vamos alterar também o nosso arquivo .def para exportar corretamente a função keepSayingSomething:

LIBRARY	"SampleDLL"
EXPORTS
  sum @1
  otherSum @2
  concat @3
  keepSayingSomething @4

Agora vamos para o nosso CSharpApp. vamos adicionar os seguintes delegates no nosso arquivo CppInterop.cs:

  public delegate void CallbackDelegate([MarshalAsAttribute(UnmanagedType.LPTStr)]string thingToSay);

  public delegate void KeepSayingSomethingDelegate(CallbackDelegate callback);

O primeiro delegate, é a representação do function pointer em C#. Estamos dizendo que o argumento thingToSay será convertido para string. O segundo delegate compatibiliza o método KeepSayingSomething. Veja que o function pointer foi inicialmente convertido para um IntPtr. No caso aqui, o delegate é automaticamente convertido para o function pointer.

Arraste um list box para o seu form (listBox1) e um novo button (button 2).

Agora, vamos declarar um novo método no Form1.cs e atribuir o código para o button 2:

    private void OnSaySomething(string thingToSay)
    {
      listBox1.Items.Insert(0, thingToSay);
    }

    private void button2_Click(object sender, EventArgs e)
    {
      if (openFileDialog1.ShowDialog() != DialogResult.OK)
        return;

      int hModule = CppInterop.LoadLibrary(openFileDialog1.FileName);

      if (hModule <= 0)
        throw new Exception("DLL not loaded.");

      IntPtr keepSayingSomethingPtr = CppInterop.GetProcAddress(hModule, "keepSayingSomething");
      KeepSayingSomethingDelegate keepSayingSomething = (KeepSayingSomethingDelegate)Marshal.GetDelegateForFunctionPointer(keepSayingSomethingPtr, typeof(KeepSayingSomethingDelegate));
      keepSayingSomething(OnSaySomething);

      CppInterop.FreeLibrary(hModule);
    }

No botão 2, repetimos o mesmo código para carregar diretamente a DLL, e depois liberar a mesma.

A parte nova do código está em executar a GetProcAddress e compatibilizar a chamada do método keepSayingSomething com o novo delegate. Até aqui nada de novo.

Já na outra linha, quando chamamos o método keepSayingSomething e passamos o método OnSaySomething como parâmetro, estamos convertendo automaticamente o delegate num function pointer. Podemos fazer isso indiretamente, através do método Marshal.GetFunctionPointerForDelegate. Este método pega um delegate do .Net e converte num function pointer C++, retornando o mesmo para um IntPtr. Nesse caso, poderíamos fazer a chamada do delegate da seguinte forma:

  public delegate void KeepSayingSomethingDelegate(IntPtr callback);

Agora executamos o programa, clicamos no button2 e… recebemos um belo erro: Run-time check failure #0 – The value of ESP was not properly saved across a function call. This is usually a result of calling a function declared with one calling convention with a function pointer declared with a different calling convention.

Vamos entender isso melhor no item abaixo:

Calling Conventions

Calling conventions (convenções de chamadas, não sei se a tradução é correta), são formas de controlar a passagem de argumentos entre funções. Elas existem pois cada compilador gera código em linguagem de máquina de forma diferente para empilhar e desempilhar os argumentos de funções.

Alguns fazem da direita para esquerda, outro da esquerda para a direita. Independente do que cada um significa, quando chamamos funções entre ambientes diferentes, temos que usar o mesmo método.

No Visual C++ quando não especificamos uma calling convention, o padrão é __cdecl. Podemos especificar outra. No .Net, quando compatibilizamos o delegate, por default ele entende __stdcall. Por isso tomamos o erro acima.

Podemos resolver o problema de duas formas. A primeira é especificar o calling convention __stdcall no C++, conforme o exemplo:

void  keepSayingSomething(void (__stdcall *callback)(LPTSTR thingToSay)){
	for(int i = 1; i <= 100; i++){
		_TCHAR str3[100];
		_stprintf_s(str3, 100, _TEXT("Saying %i"), i);
		callback(str3);
	}
}

Se você recompilar a DLL e executar novamente o programa, percebe que ele funciona normalmente.

A outra forma de resolver o problema é remover o __stdcall (o padrão do C++ é __cdecl) ou especificar o __cdecl e no C# especificar que o calling convention é cdecl, conforme os exemplos:

void  keepSayingSomething(void (__cdecl *callback)(LPTSTR thingToSay)){
	for(int i = 1; i <= 100; i++){
		_TCHAR str3[100];
		_stprintf_s(str3, 100, _TEXT("Saying %i"), i);
		callback(str3);
	}
}

No C#, precisamos anotar o delegate para especificar o calling convention

  [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
  public delegate void CallbackDelegate([MarshalAsAttribute(UnmanagedType.LPTStr)]string thingToSay);

Mais diversão com Delegates

Vamos fazer mais um exemplo divertido. Como percebemos no exemplo acima, a aplicação fala 100 vezes, de forma síncrona e o método termina.

Vamos fazer uma thread dentro da DLL que fica chamando o callback a cada 100 milissegundos para observarmos mais alguns comportamentos bacanas.

Para isso, vamos alterar nosso SampleDLL.cpp (C++):

DWORD WINAPI keepSayingSomethingThreadProc(__in  LPVOID lpParameter){
	void (__cdecl *callback)(LPTSTR) = NULL;
	callback = (void (__cdecl *)(LPTSTR)) lpParameter;
	int i = 1;
	while (true){
		_TCHAR str3[100];
		_stprintf_s(str3, 100, _TEXT("Saying %i"), i);
		callback(str3);
		i++;
		Sleep(100);
	}
	return 0;
}

void  keepSayingSomething(void (__cdecl *callback)(LPTSTR thingToSay)){
	DWORD threadID;
	HANDLE th = CreateThread(NULL, 0, &keepSayingSomethingThreadProc, callback, 0, &threadID);
}

Vamos discutir um pouco do código acima.

No momento que o método keepSayingSomething é chamado, ele chama o método CreateThread da API do Windows. Este método vai criar a thread, passando como parâmetro o método keepSayingSomethingThreadProc que será executado dentro dessa thread. O callback recebido como parâmetro, é passado para o parâmetro lpParameter do método keepSayingSomethingThreadProc e o método termina sua execução.

Já o keepSayingSomethingThreadProc fica eternamente em loop, dormindo a cada 100ms. Em cada rodada do loop, ele gera uma string concatenada, chama o callback e incrementa i.

Agora vamos alterar nosso código do button2, na aplicação C# para:

    private void updateListBox(string thingToSay)
    {
      listBox1.Items.Insert(0, thingToSay);
    }

    private void OnSaySomething(string thingToSay)
    {
      listBox1.Invoke(new CallbackDelegate(updateListBox), new object[] {thingToSay});
    }

    private void button2_Click(object sender, EventArgs e)
    {
      if (openFileDialog1.ShowDialog() != DialogResult.OK)
        return;

      int hModule = CppInterop.LoadLibrary(openFileDialog1.FileName);

      if (hModule <= 0)
        throw new Exception("DLL not loaded.");

      IntPtr keepSayingSomethingPtr = CppInterop.GetProcAddress(hModule, "keepSayingSomething");
      KeepSayingSomethingDelegate keepSayingSomething = (KeepSayingSomethingDelegate)Marshal.GetDelegateForFunctionPointer(keepSayingSomethingPtr, typeof(KeepSayingSomethingDelegate));
      keepSayingSomething(OnSaySomething);
    }

Vamos remover o FreeLibrary no final senão obviamente vai dar problemas com a thread. Deveríamos aqui controlar de forma mais elegante o carregamento e descarregamento da DLL, mas vamos ao foco da idéia dos callbacks.

O método OnSaySomething vai ser alterado para dar um listbox1.Invoke, chamando o método updateListBox. Isso é necessário pois como o callback vai ser chamado de outra thread, se não fizermos isso, vamos receber um InvalidOperationException: Operação entre threads inválida: controle ‘listBox1’ acessado de um thread que não é aquele no qual foi criado.

Vamos arrastar também um button3 e colocar o seguinte código no OnClick dele:

    private void button3_Click(object sender, EventArgs e)
    {
      GC.Collect();
    }

Agora executamos a aplicação e clicamos no botão 2. Vamos escolher selecionar a DLL e vemos o listBox enchendo de “Saying…”.

Se deixarmos a aplicação rodando um tempo, vamos receber um erro muito estranho no debugger do C#: “CallbackOnCollectedDelegate was detected”. Isso é intermitente e depende de quando o GC passa. Por isso criamos o button3. Quando rodamos o GC, o erro acontece na hora.

Esse também é divertido e para resolvermos, vamos discutir um pouco sobre delegates.

Estudando um pouco mais os delegates

O C# tem uma série de mecanismos para ajudarmos a programar melhor e mais rápido. Mas é sempre bom entender como as coisas funcionam nos bastidores para não ficarmos batendo cabeça com problemas desse tipo.

Quando declaramos um delegate, como abaixo:

  public delegate void KeepSayingSomethingDelegate(CallbackDelegate callback);

Internamente, estamos declarando uma classe que herda de System.MulticastDelegate. Essa classe tem um comportamento interessante, que é o de manter uma lista interna de métodos e quando você chama o delegate, na verdade chama vários métodos.

O exemplo abaixo deixa isso claro:

    private void button4_Click(object sender, EventArgs e)
    {
      CallbackDelegate d = new CallbackDelegate(Method1);
      d += Method2;
      d("Something");
    }

    private void Method1(string parm1)
    {
      MessageBox.Show("Method1 Invoked: " + parm1);
    }

    private void Method2(string parm1)
    {
      MessageBox.Show("Method2 Invoked: " + parm1);
    }

Declaramos um método 1 e um método 2, compatíveis com o delegate. Quando instanciamos o delegate passando como parâmetro o Method1, na verdade criamos uma classe internamente que contém uma lista e o Method1 adicionado na lista.

Quando fazemos um d += Method2; estamos na verdade adicionando nessa lista interna o Method2. O C# é legal e contém uma série de operadores pra facilitar nossa vida e fazermos escrever menos código.

Quando chamamos d(“Something”); percebemos que os dois métodos são executados.

Esse comportamento quem controla é a classe “System.MulticastDelegate”, da qual criamos uma instância sem saber.

Agora vamos fazer mais um exemplo, para entendermos outro comportamento implícito:

    private void Method1(string parm1)
    {
      MessageBox.Show("Method1 Invoked: " + parm1);
    }

    private void button5_Click(object sender, EventArgs e)
    {
      ExecuteMethod(Method1, "Something");
    }

    private void ExecuteMethod(CallbackDelegate Method, string thing){
      Method(thing);
    }

Ao clicar no button5, chamamos o método ExecuteMethod, que passa o Method1 por parâmetro. Quando fazemos essa passagem, o C# entende que o Method1 é compatível com o delegate CallbackDelegate por que sua assinatura bate com o delegate. Na verdade ele não passa o método como parâmetro. Ele cria uma instância do delegate, com um único método na sua lista interna e passa como parâmetro.

Por que isso é importante pra mim? Vamos para a próxima sessão.

CallbackOnCollectedDelegate was detected

Quando chamamos nossa DLL não gerenciada, passando um método do .Net como callback para a DLL C++, fizemos o seguinte:

      IntPtr keepSayingSomethingPtr = CppInterop.GetProcAddress(hModule, "keepSayingSomething");
      KeepSayingSomethingDelegate keepSayingSomething = (KeepSayingSomethingDelegate)Marshal.GetDelegateForFunctionPointer(keepSayingSomethingPtr, typeof(KeepSayingSomethingDelegate));
      keepSayingSomething(OnSaySomething);

No momento que fizemos keepSayingSomething(OnSaySomething);, conforme aprendemos no tópico anterior, criamos uma instância de uma classe (do delegate), adicionamos o método dentro da lista de métodos do delegate, convertemos isso num function pointer e entregamos para nossa DLL C++.

Acontece que em lugar nenhum “guardamos” essa referência do delegate (da classe que criamos internamente sem saber). Como ela ficou sem nenhuma variável apontando para essa referência, quando o garbage collector passa, ele entende como memória “livre”, vai lá e desaloca essa memória.

Nossa DLL C++ tem esse endereço guardado lá, porque foi entregue para ela como um function pointer. Quando ela chama esse método, ele já não existe mais na memória.

Divertido, né?

Para resolver esse problema de uma forma simples devemos primeiro declarar uma variável privada para o delegate:

  private CallbackDelegate callbackDelegate;

E no nosso código do button2 vamos fazer:

  private void button2_Click(object sender, EventArgs e)
    {
      if (openFileDialog1.ShowDialog() != DialogResult.OK)
        return;

      int hModule = CppInterop.LoadLibrary(openFileDialog1.FileName);

      if (hModule <= 0)
        throw new Exception("DLL not loaded.");

      IntPtr keepSayingSomethingPtr = CppInterop.GetProcAddress(hModule, "keepSayingSomething");
      KeepSayingSomethingDelegate keepSayingSomething = (KeepSayingSomethingDelegate)Marshal.GetDelegateForFunctionPointer(keepSayingSomethingPtr, typeof(KeepSayingSomethingDelegate));
      callbackDelegate = new CallbackDelegate(OnSaySomething);
      keepSayingSomething(callbackDelegate);
    }

Quando guardamos a referência na variável callbackDelegate, garantimos que o garbage collector não vai marcar o delegate como memória livre, porque guardamos a referência.

Código fonte

O código fonte dos exemplos pode ser encontrado aqui

Conclusão

Os recursos do Platform Invoke são bastante interessantes e permitem que reaproveitemos todo o nosso código gerenciado dentro de aplicações .Net.

Porém, é importante conhecermos mais a fundo um pouco mais do mundo gerenciado e do não gerenciado para não cairmos em problemas gerados pelas características de cada um deles.

Percebemos também que mesmo que a linguagem tente ser amigável e facilitar nossa vida para que consigamos escrever código melhor e mais rápido, é muito importante entender como as coisas funcionam num nível mais baixo para não gerarmos inocentemente problemas de difícil localização e correção.

Acessando DLL’s não gerenciadas no .Net – Parte 1

Objetivo

O objetivo desta primeira parte é descrever como utilizar DLL’s escritas em C/C++ (ou qualquer outra linguagem não-gerenciada) em C#. É um procedimento relativamente simples, mas existem algumas técnicas diferentes para carregamento estático e dinâmico, e ainda como compatibilizar os tipos entre as duas linguagens.

Criando uma DLL exemplo no C++

Para criar uma DLL no C++, devemos seguir os seguintes passos:

  • File | New | Project
  • Visual C++ (Muitas vezes dentro de “other languages”, principalmente para quem configurou o C# como ambiente principal no Visual Studio).
  • Win32 Project
  • Escolha o diretório
  • O wizard do Win32 Project vai ser iniciado.
  • Clique em Next
  • Escolha DLL
  • Empty Project

Crie um novo “header file”, chame ele de stdafx.h.

Crie um novo source file de nome SampleDLL.cpp, com o seguinte conteúdo:

#include "stdafx.h"
#include "windows.h"
#include "tchar.h"

int sum(int a, int b){
	return a + b;
}

void otherSum(int a, int b, int &result){
	result = a + b;
}

No caso temos dois métodos, um “sum” que retorna a soma de dois números, e outro “otherSum” que retorna a soma de dois números, por referência.

Adicione um novo “Module Definition File” (.def). Esse arquivo serve para colocar algumas definições na DLL. Nesse caso, para exportar os nomes das funções com nomes mais “amigáveis”. Vamos chamá-lo de SampleDLL.def colocar o seguinte conteúdo neste arquivo:

LIBRARY	"SampleDLL"
EXPORTS
  sum @1
  otherSum @2

Vamos compilar o projeto e partir para o lado C#.

Criando uma aplicação no C#

File | New | Project | Windows Application.

Vamos criar uma nova classe chamada CppInterop.

Dentro dessa classe, vamos colocar o seguinte código:

[DllImport("SampleDLL.dll")]
public static extern int sum(int a, int b);

[DllImport("SampleDLL.dll")]
public static extern void otherSum(int a, int b, ref int result);

O atributo “DllImport” é quem identifica qual a DLL que vai ser carregada para executar o método. A seguir a assinatura do método da DLL vai ser praticamente copiada para o C#. Nesse caso, a tradução é manual, ou seja, é necessário entender como funciona a chamada da função e usar tipos que são intercambiáveis. Falaremos mais sobre a tipagem adiante.

  • Arraste um botão para dentro do form.
  • Clique duas vezes neles.

Dentro do método button1_click, adicione o código:

int res = CppInterop.sum(10, 20);
MessageBox.Show(res.ToString());

CppInterop.otherSum(5, 2, ref res);
MessageBox.Show(res.ToString());

Agora execute e clique no botão. Vai receber um DllNotFoundException com o erro: “Não é possível carregar a DLL ‘SampleDLL.dll’: Não foi possível encontrar o módulo especificado. (Exceção de HRESULT: 0x8007007E)”

Para explicar esse erro, vamos ao próximo tópico.

Static Load x Dynamic Load

A diferença básica entre o carregamento estático (static load) e o carregamento dinâmico (dynamic load) está na dependência direta da DLL. Se você tem uma aplicação escrita em C++ e faz referências diretas a funções “externas”, está fazendo um static load.

Quando a aplicação é executada, automaticamente a DLL é procurada e caso não encontre a aplicação nem sobe.

A seqüência de busca de uma DLL é praticamente padrão no Windows:

  • Diretório System32
  • Diretório da aplicação
  • Qualquer diretório no PATH (variável de ambiente).

Já nas aplicações com carregamento dinâmico, a aplicação chega a subir, e ocorre a tentativa de subir a DLL. Nesse caso é possível a aplicação continuar executando ou mesmo mandar erros mais amigáveis. Geralmente usa-se essa abordagem em plugins.

No caso de aplicações em C# Windows Forms, percebemos um comportamento um pouco diferente. O erro só ocorre quando tentamos executar o método “extern” com o atributo DllImport, o que nos faz entender que no momento da execução do método, a DLL é carregada e o método é executado.

Vamos agora então copiar a SampleDLL.dll para dentro do diretório bin/debug e atender o segundo critério listado acima para carregamento da DLL.

Executamos a aplicação e percebemos que ela funciona. Executamos o código C++ dentro do C# através da DLL.

Outro comportamento interessante que observamos aqui é que mesmo após a execução dos métodos, se tentarmos apagar ou renomear a DLL (que teoricamente está em uso), conseguimos realizar a operação. Isso nos faz pensar que o carregamento e descarregamento da DLL a cada execução, porém examinando o resultado do executável com o ProcessExplorer, percebemos que a DLL continua carregada.

Com isso, percebemos que quando fazemos o DllImport diretamente, internamente o Platform Invoke (como a Microsoft chama essa feature de intercambiar código gerenciado com não-gerenciado) sempre utiliza carregamento dinâmico.

O atributo DllImport também nos dá outras opções para especificar diferentes entry points ou calling conventions. Não vou explorar todos os detalhes aqui.

Diferenças de Tipagens

Existem formas completamente diferentes de tratar informações no mundo gerenciado e não gerenciado. Muitos tipos são exatamente iguais, como o usado no exemplo acima, o Int32. Ele acaba tendo exatamente a mesma representação nas duas linguagens. O mesmo não corre com alguns tipos.

Esse artigo do msdn nos demonstra a compatibilidade, de forma bem simples: Platform Invoke Data Types .

A parte divertida das tipagens, está no tipo “IntPtr”. Ele foi uma forma de conseguir colocar um ponteiro dentro do C#. Tudo que é um ponteiro em C++ ou outras linguagens não gerenciadas são diretamente intercambiáveis com um IntPtr.

Devolvendo Strings do C++ para o C#

O C++ tem uma forma muito particular de tratar strings. São as famosas strings terminadas em zero ou “Null terminated strings” (ver Caractere Nulo).

Geralmente quem vem do mundo C#, Java ou mesmo Delphi, tem tipos strings que fazem todo o controle de tamanho, memória, etc. Em C++ existe também esse tipo, mas geralmente não é usado, por ser “caro” demais. Programadores C++ geralmente tem performance correndo pelas veias.

Geralmente passa-se um ponteiro para o primeiro caractere da string e a vai-se incrementando o caractere até achar um caractere #0 (null). Isso indica que acabou a string e a memória dali pra frente passa a ser “desconhecida”. Outros níveis de complexidade começam a aparecer quando a string deixa de ser uma string ASCII/ANSI (um byte por caractere) e passa a ser Unicode ou Multibyte. A idéia deste artigo não é tentar explicar isso.

Tendo em mente essa idéia, geralmente quando é necessário devolver uma string terminada em zero em linguagens não gerenciadas, ocorre um problema quanto à alocação da memória necessária para a string.

  • Se dentro do C++ você declarar um array de caracteres como uma variável local e devolver o ponteiro, vai gerar um belíssimo bug, pois a memória da pilha é liberada e o ponteiro passa a ser inválido.
  • Se dentro do C++ você alocar a memória no heap com um new ou um malloc e devolver o ponteiro, quem chamou a função precisa “lembrar” de desalocar a memória. Essa é uma bela forma de gerar um vazamento de memória (memory leak).
  • A solução comumente usada para esse problema é quem está chamando a função alocar um buffer e passar o ponteiro para o início do buffer e o seu tamanho máximo. É praticamente uma convenção no mundo C/C++ essa abordagem.

Com isso, vamos fazer um novo método na nossa DLL C++ para concatenar duas strings. É um método bastante inútil, visto que já existe o strcat. Mas para fins didáticos, é interessante. Vamos ao seu conteúdo no C++:


void concat(LPTSTR src1, LPTSTR src2, LPTSTR dest, int destSize){
	_tcscpy_s(dest, destSize, src1);
	_tcscat_s(dest, destSize, src2);
}

O tipo LPTSTR no C++ indica um “ponteiro para uma string terminada em zero”, porém o LPTSTR tem uma série de defines dentro do windows.h e tchar.h, para fazer com que, caso a aplicação seja compilado com _UNICODE, os tipos sejam convertidos internamente para 2 bytes por caractere (O Windows trata todos os caracteres com 2 bytes em Unicode). Caso seja compilado sem _UNICODE, vai um byte por caractere. Aqui vamos assumir que vai ser compilado como _UNICODE.

A idéia desse método é devolver em “dest” as strings src1 e src2 concatenadas. “destSize” é o tamanho do buffer pré-alocado em dest.

_tscpy_s é uma macro que traduz para uma função que copia uma string para outro buffer de forma “segura” (evitando o estouro do buffer). Por isso o parâmetro destSize é passado. A macro é para garantir a questão da compilação com ou sem _UNICODE.

_tsccat_s é uma macro que traduz para uma função que concatena duas strings de uma forma segura.

Como fica isso no C#?

Vamos alterar o nosso CppInterop.cs e adicionar o seguinte método:

[DllImport("SampleDLL.dll")]
public static extern void concat([MarshalAsAttribute(UnmanagedType.LPWStr)]string src1,
  [MarshalAsAttribute(UnmanagedType.LPWStr)]string src2, IntPtr dest, int destSize);

O MarshalAsAttribute (UnmanagedType.LPWStr) é um atributo usado no argumento da função, indicando que no momento da tradução, no lado “Unmanaged” temos um LPWStr (ponteiro para caractere de 2 bytes), ou seja, no momento do “Invoke” da função, o Platform Invoke vai fazer o trabalho sujo de tradução para nós. Aqui se usássemos um LPTStr, o resultado seria o mesmo, já que no lado “Unmanaged” sabemos que temos uma string de caracteres de 2 bytes. Eu coloquei diferente justamente para ilustrar que o Platform invoke não faz “checagens” dos tipos do lado de lá. Ele assume que o tipo do lado de lá, executa o método e vamos ver o que acontece (as vezes cai em memória indevida e dá Access Violation).

Os dois primeiros parâmetros serão passados como strings e traduzidas. Se o terceiro parâmetro é um LPTSTR no C++, por que foi traduzido como um IntPtr no C#?

A resposta é que a idéia desse parâmetro é passar o ponteiro para um “buffer” onde a função vai responder a string concatenada. Ou seja, eu vou dar um espaço de memória para o C++ escrever e depois vou ler o que tem dentro. O destSize é justamente o tamanho desse buffer, para evitar que o C++ escreva em memória indevida.

A chamada dessa função no C# fica da seguinte forma:

IntPtr p = Marshal.AllocHGlobal(100);
CppInterop.concat("String", " concatenation", p, 100);
MessageBox.Show(Marshal.PtrToStringUni(p));
Marshal.FreeHGlobal(p);

Vamos tentar entender o código acima.

A primeira linha, vai alocar um buffer de 100 bytes e retornar o ponteiro para um IntPtr.

A segunda, vai executar o método concat, convertendo os dois primeiros parâmetros e dando o buffer pré-alocado no terceiro parâmetro e indicando pro C++ q ele pode escrever em até 100 bytes.

A terceira linha, usa métodos auxiliares da classe Marshal, o PtrToStringUni, que vai converter o conteúdo do buffer para uma string em C#, considerando que o que tem apontado é uma string terminada em zero Unicode (existem as versões multi-byte e ANSI).

A quarta linha é para liberar a memória alocada na primeira linha. Ou seja, o buffer que eu pré-aloquei. Quem disse que o C# não vaza memória? :-P. É interessante colocar blocos como esse dentro de um try/finally.

Outro exercício interessante que podemos fazer com esse exemplo é mudar o tamanho do buffer em AllocHGlobal e na chamada do concat para “10”. Com isso, vamos perceber que vai estourar uma EAccessViolationException no C#. Por incrível que pareça isso é bom.

A função _tcscat_s, verifica o tamanho do buffer e estoura uma exceção que é retornada para o C#.

Se quisermos ver uma coisa mais divertida, mantemos o tamanho do buffer com 10 e na DLL C++, alteramos as chamadas de _tcscpy_s para _tsccpy e _tcscat_s para _tcscat.

O efeito que obtemos é mais divertido, por que a string é concatenada mas escreve numa área de memória que sabe-se lá o que tem dentro. O C# chega a mostrar a string, porém dá uma exceção EAccessViolationException. O C# consegue identificar que está lendo memória “inválida”, porém, já houve a gravação nessa memória.

Conclusão

Observamos que o C# tem vários recursos interessantes para acessar código escrito em linguagens não gerenciadas. Mesmo funções da API do Windows podem ser chamadas diretamente desta forma.

Percebemos também que é necessário conhecermos muito bem o funcionamento das chamadas em código não gerenciado, já que a tradução dos tipos não é simples e totalmente intuitiva.

Nas próximas partes desse artigo pretendo abordar alguns tópicos um pouco mais avançados sobre o assunto.

Gostaria de agradecer os amigos Marcos Capelini e Rodrigo Strauss por me ajudarem bastante com as práticas e culturas mundo C/C++.

Código fonte

Baixe o código fonte aqui.