Não é à toa que meus últimos posts estão trazendo assuntos referentes a listas ligadas e performance. Nas últimas semanas, fui contratado por uma empresa de segurança para dar uma olhada em um de seus filtros de File System, a fim de diminuir o atraso causado por eles. Neste post vou falar sobre sincronismo e contenção de CPU.
Acho que não deve ser novidade para muitos aqui que uma lista ligada, ou qualquer outro recurso, quando compartilhado entre várias threads, deve implementar algum sincronismo de acesso, evitando assim, que uma thread leia dados inválidos em conseqüência de uma alteração realizada por outra thread.
Mas eu não tenho dois processadores, para quê sincronismo?
É verdade que computadores com um processador, em um dado instante, executam apenas uma thread. Assim, nunca teremos duas threads sendo executadas ao mesmo tempo. Mas é importante lembrar que threads são executadas em pequenas fatias de tempo que são determinadas pelo scheduler, levando em consideração o tamanho do quantum e a prioridade de cada thread. Existem meios de evitar que uma thread seja interrompida pelo scheduler do Windows, mas em condições normais de temperatura e pressão, não sabemos quando uma thread será interrompida para que outra passe a ser executada.
Suponha que a thread A esteja varrendo uma lista à procura de um determinado nó. Esta thread chega ao registro R e é interrompida para que a thread B possa ser executada. A thread B remove o mesmo registro R da lista e o desaloca da RAM. Quando a thread A é retomada e consulta os campos do finado registro R, esta acessará dados inválidos e tornará os passos seguintes imprevisíveis. Se bem que é bem previsível que algo azul deva acontecer.
Existem vários mecanismos de sincronismo que podemos utilizar, mas neste post, vou comentar especificamente sobre os envolvidos nestas semanas. Mas além destes, sou obrigado a falar sobre o mais exótico que já vi em anos de experiência, que foi por mim apelidado de “Mutex, pero no mutcho”. Esta era uma classe derivada da classe VMutex do VToolsD. O método enter() desta classe tentava insanamente adquirir o mutex algumas milhares de vezes em um loop. Se depois destas milhares de interações, o mutex ainda não fosse adquirido, então a thread se dava por satisfeita e acessava o recurso de qualquer maneira. Será que o sistema estava tendo algum problema de Dead Lock? De qualquer forma, foi um prazer corrigir isso anos atrás. Tem coisas que a gente só acredita vendo.
Conforme eu já comentei num outro post, filtro de File System é uma camada posta sobre os drivers FASTFAT, NTFS, CDFS, Network Redirectors e quaisquer outros drivers que implementem a interface de sistema de arquivos. Ou seja, todas as operações referentes a arquivos, inclusive os acessos com File Mapping passam pelos filtros de File System.
Se você não está só interessado em saber a respeito de filtros de File System, e sim interessado em trabalhar com eles, então você tem a obrigação de ler o Windows NT File System Internals de Rajeev Nagar. Este livro é a única referência reconhecidamente abrangente o suficiente sobre este assunto. Publicado inicialmente em 1997 pela O’Reilly, este livro ainda é a ferramenta obrigatória para o desenvolvimento referente a File Systems mesmo para o Windows Vista. Sua publicação foi interrompida durante alguns anos, e durante essa época, eu mesmo cheguei a ver o preço em mais de U$ 200.00 por único um exemplar usado na Amazon. Hoje a OSR detém os direitos do livro e atualmente está trabalhando em uma edição atualizada, que deve trazer assuntos tais como o Shadow Copy, o NTFS transacional, Filter Manager, Mini-Redirectors e a desmontagem forçada de volumes. Entretanto este é um trabalho que ainda levará algum tempo, então em 2005, a versão original foi reimpressa para suprir essa necessidade enquanto a nova edição é composta.
O filtro que tenho trabalhado mantem várias listas que devem ser consultadas a cada acesso interceptado. O mecanismo utilizado para sincronizar o acesso a estas listas era o Spin Lock. Uma escolha óbvia, mas inadequada para este cenário, onde a atividade é muito intensa. O grupo de listas que é consultado na IRP_MJ_READ é o mesmo utilizado na IRP_MJ_WRITE e em outros eventos. O resultado disso é a contenção de CPU. Ou seja, quando uma thread adquire um Spin Lock para realizar uma consulta na lista, todas as outras threads que precisam consultar a mesma lista precisam sentar e esperar até que o Spin Lock seja liberado (mesmo em casos onde temos mais de um processador). Sabendo que estas listas não são pequenas, adivinha se ficava lento…
Assim como a fome mundial, o câncer de colo do útero, o exame de próstata e outros males que atingem a humanidade, algo devia ser feito a respeito. Por isso, numa incansável luta contra as forças do mal foi criado o ERESOURCE. (Cantos angelicais e uma névoa de gelo seco se dissipa no chão)
Utilizando o ERESOURCE
ERESOURCE é o meio mais adequado e nativamente utilizado para sincronizar acessos às estruturas que fazem parte de sistemas de arquivos, tais como o FCB (File Control Block), que além de outras informações, mantém o tamanho atual do arquivo. Com o ERESOURCE, várias threads podem ter acesso à mesma lista ligada ao mesmo tempo para leitura. Admitindo que nenhuma das threads vai alterar dados da lista, então todos os acessos poderiam ser realizados simultaneamente. Em contrapartida, se necessário for, uma thread pode ganhar acesso exclusivo à lista a fim de fazer alguma alteração.
Para utilizar o ERESOURSE, você precisa declarar uma variável do tipo ERESOURCE, que deve residir em memória não paginada e alinhada em 8 bytes, e inicializá-la utilizando a função ExInitializeResourceLite. Para ter acesso compartihado (somente leitura) às listas, você deve utilizar a função ExAcquireResourceSharedLite. Esta função verifica a existência de alguma thread com acesso exclusivo sobre o recurso controlado. Caso haja, a função pode, dependendo de um parâmetro, retornar falha ou aguardar até que o recurso seja liberado. Para ter acesso exclusivo, utilize a função ExAcquireResourceExclusiveLite, que análogamente verifica a existência de threads com acesso compartilhado sobre o recurso, e opcionalmente, aguarda até que todas as threads com acesso compartilhado liberem o recurso para que o acesso exclusivo seja dado. Finalmente, para liberar o acesso adquirido, seja exclusivo ou compartilhado, utilize a função ExReleaseResourceLite.
Um ponto interessante a ser notado é que a entrega de Kernel APC precisa ser desabilitada para as threads que adquirirem o ERESOURCE. Não conheço o real motivo desta necessidade, mas no mínimo evita que a thread que detém o acesso ao recurso controlado seja terminada por uma outra thread, isso sabendo que o TerminateThread é implementado via Kernel APC. Desta forma, a maneira mais comum de utilizar essas funções é como demonstrado abaixo.
//-f--> Obtem acesso compartilhado (read only)
KeEnterCriticalRegion();
ExAcquireResourceSharedLite(&m_Resource, TRUE);
//-f--> Realiza consultas ao recurso
//-f--> Libera o recurso
ExReleaseResourceLite(&m_Resource);
KeLeaveCriticalRegion();
Um ponto negativo é que a maioria das funções que lidam com ERESOURCE, diferente dos Spin Locks, devem ser chamadas em IRQL < DISPATCH_LEVEL. Desta forma, talvez sejam necessárias algumas manobras com as IRPs e suas CompletionsRoutines para lidar com isso.
Feitas as modificações, o sistema ganhou muito em performance nos testes que fiz. O ganho será ainda maior à medida que tivermos mais operações em paralelo, e obviamente, em micros com mais de um processador.
E todos viveram felizes para sempre…