RPC Talk: problemas de conexão

RPC Talk: problemas de conexão

o que é conexão

Não existe conexão no mundo físico. Depois que os dados são convertidos em um sinal óptico/elétrico, eles são enviados de uma máquina para outra. O dispositivo intermediário analisa as informações de destino por meio do sinal para determinar como encaminhar o pacote. A chamada "conexão" em nossa vida cotidiana é puramente um conceito abstrato artificial. O objetivo é classificar os dados sem estado transmitidos em diferentes sessões com estado por meio de um campo fixo como identificador, de modo a facilitar a implementação de algum estado dependente tarefas na camada de transporte.

Tomando o TCP como exemplo, o handshake inicial de três vias é usado para confirmar um número de sequência inicial (Initial Sequence Numbers, ISN) em ambos os lados. Esse ISN marca uma sessão TCP e essa sessão possui cinco tuplas exclusivas (IP de origem endereço, porta de origem, endereço IP de destino, porta de destino, protocolo da camada de transporte). No sentido físico, uma sessão TCP é equivalente a uma rota relativamente fixa para um determinado servidor (ou seja, um conjunto fixo de dispositivos físicos intermediários). Por isso, realizamos controle de congestionamento de estado e outras operações para cada sessão TCP é significativa .

sobrecarga de conexão

Muitas vezes ouvimos que a operação e manutenção dizem que uma determinada máquina tem muitas conexões, então há jitter de serviço.Na maioria das vezes, aceitaremos essa afirmação e tentaremos reduzir o número de conexões. No entanto, raramente pensamos em um problema. Quando há muitas conexões a um serviço, a CPU, a memória e a placa de rede da máquina geralmente têm muitos recursos livres. Por que eles ainda tremem? Quais são os custos específicos de manter uma conexão?

Sobrecarga de memória:

A pilha do protocolo TCP geralmente é implementada pelo sistema operacional. Como a conexão é um par com informações de estado, o sistema operacional precisa salvar as informações dessa sessão na memória. A sobrecarga de memória para cada conexão é inferior a 4 KB.

Ocupação do descritor de arquivo:

Da perspectiva do Linux, cada conexão é um arquivo e ocupa um descritor de arquivo. A memória ocupada pelo descritor de arquivo foi calculada na sobrecarga de memória acima, mas para proteger sua própria estabilidade e segurança, o sistema operacional limitará o número máximo de descritores de arquivo que podem ser abertos simultaneamente em todo o sistema e em cada processo:

Configuração da máquina: Linux 1 núcleo 1 GB

$ cat /proc/sys/fs/file-max
97292

A configuração acima de $ ulimit -n
1024
significa que todo o sistema operacional pode abrir até 97292 arquivos ao mesmo tempo e cada processo pode abrir até 1024 arquivos ao mesmo tempo.

Estritamente falando, um descritor de arquivo não é um recurso, o recurso real é a memória. Se você tiver uma necessidade clara, poderá permitir que todos os aplicativos ignorem essa limitação definindo um valor máximo.

Sobrecarga do tópico:

Algumas implementações mais antigas do Server ainda fornecem serviços para cada conexão exclusivamente (recém-criada ou obtida do pool de conexões) com um thread. Para esse tipo de serviço, além da conexão em si, também existem threads fixos. Sobrecarga de memória:

Configuração da máquina: Linux 1 núcleo 1 GB

número máximo de threads do sistema operacional

$ cat /proc/sys/kernel/threads-max
7619

O número máximo de threads em um único processo do sistema operacional, undef significa ilimitado

$ cat /usr/include/bits/local_lim.h
/* Não temos limite predefinido para o número de threads. */
#undef PTHREAD_THREADS_MAX

O tamanho padrão de uma única pilha de thread, em KB

$ ulimit -s
8192
Na máquina acima, o número de threads que podem ser criados é limitado, por um lado, pelo próprio valor de configuração do sistema operacional e, por outro lado, pelo tamanho da memória. Como 1024 MB / 8 MB = 128 > 7619, o número máximo de threads que podem ser criados nesta máquina é 128. Se o servidor usar um encadeamento e uma conexão, o servidor só poderá fornecer serviços para até 128 conexões ao mesmo tempo.

Pode-se ver que esse modo de thread único de conexão única fará com que o número de conexões seja bastante restrito pelo número de threads; portanto, a maioria das implementações de servidor modernas abandona esse modo e permite que um único thread lide exclusivamente com as conexões.

Problema C10K
Através da discussão acima, podemos ver que o que realmente restringe o número de conexões são essencialmente os recursos de memória. Outras variáveis ​​podem ser ignoradas modificando os parâmetros padrão ou otimizadas alterando o design do software. Mas se é tão simples assim, por que existe o famoso problema C10K?

Na verdade, isso é puramente uma questão de engenharia de software, não de hardware. Quando o sistema operacional inicial foi projetado, ele não considerou o problema de conexões de 10K ou mais no futuro, então a interface não foi otimizada para tais cenários, e o software de infraestrutura (como o Apache) naturalmente não há consideração de como lidar com tais cenários.

Para os desenvolvedores de software de aplicativos, o sistema operacional é como a lei. O que podemos fazer não se baseia apenas no que o mundo físico pode fazer, mas também no que o sistema operacional nos permite fazer.

Deve-se notar que o problema C10K refere-se ao número de conexões, não ao número de requisições. Se 10K QPS (Consulta por segundo) também pode ser executado em uma conexão, é por isso que geralmente não há problema C10K para chamadas RPC na intranet corporativa. O problema C10K geralmente ocorre em cenários como serviços push e serviços de mensagens instantâneas que precisam estabelecer conexões persistentes com um grande número de clientes.

No contexto do Linux, as conexões são abstraídas como arquivos, portanto, a chave para o problema C10K é se o design da interface IO fornecido no Linux pode lidar com cenários de conexão em larga escala e como usamos essas interfaces para implementar software que pode suportar alta concorrência arquitetura de conexões.

A evolução histórica do Linux IO
Se quisermos processar várias conexões (ou seja, vários descritores de arquivo) ao mesmo tempo, o sistema operacional deve ser capaz de nos fornecer uma função de monitoramento em lote, permitindo monitorar vários descritores de arquivo ao mesmo tempo. ao mesmo tempo, e através O valor de retorno nos diz quais arquivos são legíveis/graváveis, e então podemos realmente operar esses descritores de arquivo prontos.

O trabalho fundamental do Linux IO nada mais é do que o descrito acima. Não parece ser um projeto complicado, mas os diferentes métodos de implementação dessa parte do trabalho na história afetaram profundamente o desenvolvimento de software de aplicativo posterior e também é o principal diferença entre muitos softwares básicos.

select, 1993
select 的函数签名:

#define __FD_SETSIZE 1024

typedef struct
  {
    
    
    __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
  } fd_set;

int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
          struct timeval *timeout)
使用方式:

// 初始化文件描述符数组
fd_set readfds;
FD_ZERO(&readfds);

// socket1, socket2 连接注册进 readfds
FD_SET(conn_sockfd_1, &readfds);
FD_SET(conn_sockfd_2, &readfds);

// 循环监听 readfds
while(1)
{
    
    
    // 返回就绪描述符的数目
    available_fd_count = select(maxfd, &readfds, NULL, NULL, &timeout);

    // 遍历当前所有文件描述符
    for(fd = 0; fd < maxfd; fd++)
    {
    
    
        // 检查是否可读
        if(FD_ISSET(fd, &readfds))
        {
    
    
            // 从 fd 读取
            read(fd, &buf, 1);
        }
    }
}

Os defeitos da função selecionada na conexão em grande escala residem principalmente nos dois aspectos a seguir:

Atravesse linearmente todos os descritores de arquivo, complexidade O(N):

A própria função select não retorna quais descritores de arquivo específicos estão prontos, e o usuário precisa percorrer todos os descritores de arquivo sozinho e julgar por meio de FD_ISSET. O impacto não é grande quando há poucas conexões, mas quando o número de conexões chega a 10K, o desperdício causado por essa complexidade O(N) será muito exagerado.

limite de tamanho fd_set:

Atrás da estrutura fd_set está um bitmap, cada bit representa um descritor de arquivo, mas o status pronto, 1 significa pronto. O FD_SETSIZE padrão do Linux é 1024, ou seja, o tamanho real é 1024/8bits = 128 bytes. E essa parte da memória acabará sendo copiada para o estado do kernel, o que também causará sobrecarga de cópia.

Esse design parece grosseiro, mas a vantagem é que ele pode lidar bem com o problema de filas. Se uma conexão estiver particularmente ocupada, isso não afetará o desempenho da chamada do sistema em si, porque ela se preocupa apenas se está pronta e não se preocupa com a quantidade de dados que está esperando para ser processada. Veja mais discussões sobre isso no correio de Linus de 2000.

enquete, 1998
A assinatura da função de enquete:

struct pollfd
  {
    
    
    int fd;             /* File descriptor to poll.  */
    short int events;   /* Types of events poller cares about.  */
    short int revents;  /* Types of events that actually occurred.  */
  };

int poll (struct pollfd *fds, nfds_t nfds, int timeout)
使用方式:

// 初始化 pollfd 数组
int nfds = 2
struct pollfd fds[fds_size];
fds[0].fd = STDIN_FILENO;
fds[0].events = POLLIN;
fds[1].fd = STDOUT_FILENO;
fds[1].events = POLLOUT;

// 监听 pollfd 数组内的文件描述符
poll(fds, nfds, TIMEOUT * 1000);

// 遍历 fds
for(fd = 0; fd < nfds; fd++)
{
    
    
    // 是否是读取事件
    if (fds[fd].revents & POLLIN)
    {
    
    
        // 从 fd 读取
        read(fds[fd].fd, &buf, 1);
    }
}

Existem duas diferenças principais entre votação e seleção:

Unifique os três tipos de evento de readfds, writefds e exceptfds por meio de pollfd.
Os descritores de arquivo a serem monitorados são passados ​​pela matriz pollfd[] e o número de descritores de arquivo não é mais limitado. (O kernel converte a matriz em uma lista encadeada).
Mas em 1998, quando a pesquisa foi inventada, a infra-estrutura de rede em grande escala ainda não era um requisito comum, então esse aumento de API não resolveu o problema mencionado acima de percorrer todos os descritores de arquivo em conexões de grande escala. Mas algumas coisas aconteceram em 1999, logo após o lançamento da pesquisa:

A questão C10K foi levantada oficialmente
e o HTTP 1.1 foi lançado. Nesta versão, o conceito de manter a conexão persistente viva foi introduzido .
O QQ foi lançado
e por volta de 2000, a Internet 2C inaugurou a era da grande explosão e da grande bolha.

Na época em que o QQ foi lançado, esse problema era insolúvel, e é por isso que um aplicativo orientado a sessão como o QQ abandonou o protocolo TCP orientado a sessão e usou o UDP.

epoll, 2003
assinatura da função epoll:

typedef union epoll_data {
    
    
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    
    
    uint32_t     events;    /* Epoll events */
    epoll_data_t data;      /* User data variable */
};

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
                 int maxevents, int timeout);

Como usar:

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

// 创建 epollfd 对象,后续 epoll 操作都围绕该对象
epollfd = epoll_create(10);

// 对 ev 绑定关心对 EPOLLIN 事件,并注册进 epollfd 中
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if(epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    
    
   perror("epoll_ctl: listen_sock");
   exit(EXIT_FAILURE);
}

for(;;) {
    
    
    // 传入 events 空数组,阻塞等待直到一有就绪事件便返回,返回值为有效事件数
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
    
    
        perror("epoll_pwait");
        exit(EXIT_FAILURE);
    }

    // 只需要遍历有效事件即可
    for (n = 0; n < nfds; ++n) {
    
    
        if (events[n].data.fd != listen_sock) {
    
    
            //处理文件描述符,read/write
            do_use_fd(events[n].data.fd);
        } else {
    
    
            //主监听socket有新连接
            conn_sock = accept(listen_sock,
                            (struct sockaddr *) &local, &addrlen);
            if (conn_sock == -1) {
    
    
                perror("accept");
                exit(EXIT_FAILURE);
            }
            setnonblocking(conn_sock);
            
            //将新连接注册到 epollfd 中,并以边缘触发方式监听读事件
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = conn_sock;
            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                        &ev) == -1) {
    
    
                perror("epoll_ctl: conn_sock");
                exit(EXIT_FAILURE);
            }
        }
    }
}

epoll tem as seguintes características:

Cada descritor de arquivo será copiado apenas uma vez por epoll_ctl quando for criado.
epoll_wait tem apenas parâmetros limitados, o que evita cópias frequentes do modo de usuário para o modo de kernel.
epoll_wait retorna apenas descritores de arquivo prontos, evitando percorrer todos os descritores de arquivo.
Portanto, quando o número de conexões aumenta linearmente, o desempenho da própria chamada epoll não aumenta linearmente. A maioria dos servidores modernos passou a usar o epoll na plataforma Linux.

Design de serviço de alta simultaneidade
Podemos decompor o problema C10K nos três subproblemas a seguir:

  1. Como estabelecer com eficiência um grande número de conexões (aceitar)
  2. Se você ler e escrever um grande número de conexões de forma eficiente (leitura/gravação)
  3. Como lidar eficientemente com um grande número de solicitações

A diferença entre esses três subproblemas é que o estabelecimento de uma conexão só ocupará a CPU no início, e somente os recursos de memória serão ocupados após o estabelecimento da conexão, sendo fixos os recursos consumidos cada vez que uma conexão for estabelecida. No entanto, as operações de leitura e gravação em cada conexão e o processamento dos dados solicitados frequentemente consumirão recursos imprevisíveis de CPU e memória, e as diferenças entre conexões e solicitações serão muito grandes.

Como estabelecer conexões com eficiência

Como os recursos consumidos para estabelecer uma conexão são fixos, assumindo que x ms é necessário, se usarmos um único thread, ele será responsável apenas por ouvir o descritor de arquivo da porta de escuta, criando uma nova conexão, mas não será responsável por lê-lo e escrevê-lo. Em seguida, o número de conexões que o thread pode criar por segundo deve ser 1000/x.

De um modo geral, o consumo de criação de uma conexão em si é muito pequeno e um único thread é suficiente para lidar com 10K ou até mais simultaneidade.

Como ler e escrever conexões com eficiência

Garantimos que o serviço possa estabelecer novas conexões com eficiência na etapa anterior, mas não podemos estimar com precisão a carga de trabalho das tarefas de leitura e gravação para essas novas conexões, portanto, precisamos de um pool de threads para usar epoll_wait para monitorar eventos de conexão em lotes e executar operações reais de leitura e gravação. Mas as operações de leitura e gravação aqui envolvem dois modos de notificação de epoll——acionamento horizontal e acionamento de borda.

Para select/poll, obtém-se a lista de descritores de arquivo prontos. Cada chamada verificará apenas se é legível/gravável. Depois de obter os descritores disponíveis, leia e escreva e, em seguida, prossiga para a próxima seleção/poll. Se você não terminou de ler, da próxima vez que chamar select/poll, ele continuará voltando ao estado legível, desde que você continue lendo, não há problema. Se não for concluído, quando retornar ao estado gravável na próxima vez, poderá continuar a gravar. O epoll também possui esse tipo de modo, e chamamos isso de acionamento do nível de processamento.

O gatilho de borda corresponde ao gatilho horizontal, e seu nome vem do conceito de nível:

Nível acionado:

| |
| | _

Borda acionada:
____
| |
.| | _

// "." significa notificação de disparo
O disparo de borda é notificado apenas uma vez quando não há dados e há dados, e as chamadas subsequentes retornam False. Portanto, ao receber a notificação, você deve ler todo o conteúdo do arquivo de uma só vez. E se uma conexão estiver extremamente ocupada, ocorrerá inanição neste momento. Mas esse tipo de fenômeno de starvation tem pouco a ver com epoll ou disparo de borda. É apenas que precisamos considerar o equilíbrio entre leitura e escrita quando implementamos o código. Se você sempre tentar ler todo o conteúdo de uma vez no modo acionado por nível, ainda haverá fome.

Para a grande maioria das mensagens de pequeno volume, não importa qual método de disparo pode ler rapidamente a mensagem, há pouca diferença. Mas para mensagens de grande volume, como vídeo, o método de gatilho horizontal fará com que o epoll_wait seja ativado com frequência e haverá muito mais chamadas de sistema em comparação com o gatilho de borda, portanto, o desempenho será pior.

Como lidar com solicitações de forma eficiente

Para a maioria das empresas, o processamento da lógica de negócios é a operação real que consome recursos, portanto, não podemos colocar essa parte da operação no encadeamento de E/S, caso contrário, isso afetará outras conexões monitoradas neste encadeamento. Portanto, é necessário abrir um pool de threads de trabalho separado para processar a própria lógica de negócios.

No final das contas, para tarefas baratas e constantes, você pode usar um único thread. Para tarefas com consumo indeterminado, você precisa usar um pool de threads.

Arquitetura final
Por meio da série acima de divisão de tarefas, podemos obter um modelo de serviço que suporta alta simultaneidade chamado reator mestre-escravo na indústria:

 单线程                         线程池                         线程池

[ Main Reactor ] == nova conexão ==> [ Sub Reactor ] <-- Data --> [ Worker Thread ]
Estabelecer uma nova conexão Lê e escreve conexão processamento de lógica de negócios
Este modelo também é a implementação da estrutura Netty de Java e gnet de Go quadro Base.

O negócio de Internet tem dividendos, e a transformação do software de infraestrutura também tem dividendos. Epoll é um dos maiores dividendos dos últimos 10 anos.

Pooling de conexões vs multiplexação

Existem duas ideias básicas para o gerenciamento de conexões: pooling de conexões e multiplexação.

A conexão é a portadora de transmissão da solicitação, e uma solicitação inclui uma ida e volta, ou seja, um tempo RTT. Pool de conexão geralmente significa que cada conexão atende apenas uma solicitação ao mesmo tempo, ou seja, haverá apenas uma solicitação em um RTT. Neste momento, se houver um grande número de solicitações simultâneas, um pool de conexão deve ser usado para gerenciar o ciclo de vida. Mas a conexão em si é full-duplex. É possível enviar solicitação e resposta o tempo todo. Esse também é o significado de multiplexação, mas requer suporte do protocolo da camada de aplicação para marcar um ID para cada pacote para dividir diferentes solicitações e respostas .

Como o protocolo HTTP 1.0 não marca IDs para cada solicitação, é impossível oferecer suporte à multiplexação na implementação. No protocolo HTTP 1.1, são adicionados os conceitos de conexão Keep-Alive e Pipeline. As requisições podem ser enviadas continuamente, mas a ordem em que o Response retorna deve ser a ordem em que foram enviadas, de forma a reutilizar as conexões o máximo possível . No entanto, os requisitos do Pipeline para a ordem de resposta farão com que, se uma determinada solicitação demorar muito para ser processada, os retornos subsequentes continuarão se acumulando. 1.1 Como é uma revisão de 1.0, é improvável que adicione muitas alterações incompatíveis. Mas no HTTP 2.0, o ID do fluxo é adicionado para realizar a capacidade de multiplexação.

O protocolo Thrift também marcará seu próprio ID de número de série no início (8 a 12 bytes), portanto, também pode suportar bem a multiplexação.

No entanto, a maioria dos protocolos de banco de dados convencionais, como o Mysql, não oferece suporte à multiplexação de conexão, e é por isso que geralmente precisamos configurar conjuntos de conexões de banco de dados. A maior parte do tempo do banco de dados é consumida em cálculos de E/S de disco e CPU. O uso do pool de conexão pode garantir uma quantidade limitada de solicitações simultâneas e as tarefas são concluídas uma a uma. Se houver uma situação ocupada, as solicitações serão bloqueadas principalmente no lado do cliente. Se for um método de multiplexação, o cliente não tem como estimar com precisão a capacidade de carga do servidor e um grande número de solicitações ainda será enviado e bloqueado no banco de dados, o que muitas vezes não queremos. ver. Além disso, a forma de pool de conexão costuma ser mais fácil para a implementação do Client.

Server Push
Todos nós sabemos que a conexão em si é full-duplex, e você pode enviar qualquer mensagem entre Cliente e Servidor, sem falar que a mensagem de retorno de uma solicitação em si é um Server Push literal. Então, por que o HTTP 2.0 e alguns protocolos RPC anunciam que oferecem suporte ao Server Push?

Esta questão em si é novamente uma questão de engenharia de software. Estamos acostumados a programar no modo de entrada e saída, portanto, ao projetar o protocolo, raramente consideramos a situação em que a entrada não tem saída e a saída não tem entrada. Server Push é uma situação em que não há saída, mas nenhuma entrada.

No HTTP 1.1, uma mensagem completa deve ser a seguinte:

=== Solicitação ===
GET /hello.txt HTTP/1.1
User-Agent: curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
Host: www.example.com
Accept-Language: então, mi

=== Resposta ===
HTTP/1.1 200 OK
Data: segunda-feira, 27 de julho de 2009 12:28:53 GMT
Servidor: Apache
Última modificação: quarta-feira, 22 de julho de 2009 19:15:56 GMT
ETag: “34aa387-d- 1568eb00”
Faixas de aceitação: bytes
Tamanho do conteúdo: 51
Variação: Codificação aceita
Tipo de conteúdo: texto/simples

Olá, mundo! Minha carga útil inclui um CRLF à direita.
Cada resposta deve corresponder a uma solicitação, que é uma chamada de função do lado do código. Imagine se o servidor estiver na conexão neste momento e de alguma forma retornar uma resposta que o cliente não solicitou, o que pode ser feito na implementação do código? O código não é chamado de forma alguma, portanto, naturalmente, não há lugar para ouvir e esperar por essa resposta e, naturalmente, não há lugar para processá-lo.

O chamado long polling implementado pelas gerações posteriores no HTTP 1.1 nada mais é do que usar as características de conexões longas para atrasar o envio de mensagens. Não é tanto uma nova tecnologia, não é um truque oportunista, mas é de fato uma solução útil em cenários simples. Para resolver completamente o problema, o protocolo deve ser modificado, por isso existem WebSocket e HTTP 2.0.

No HTTP 2.0, o quadro especial PUSH_PROMISE é marcado para indicar que a mensagem não corresponde à solicitação, mas na implementação real, será descoberto que mesmo que o servidor possa enviar conteúdo para o cliente, o cliente ainda precisa analisar o significado específico de diferentes mensagens. No modo tradicional, o significado de uma Resposta é determinado pela Solicitação, mas agora uma Resposta sem Solicitação só pode ser determinada pela análise de seu conteúdo. Isso levou ao fato de que o processo de realização dessa análise pode realmente ser definido por cada família. Para padrões de navegador, o Server Push geralmente é usado para recursos estáticos, portanto, é necessário estabelecer um conjunto de padrões de cache de recursos.

No grpc, embora o HTTP 2.0 seja usado na camada inferior, a função PUSH_PROMISE não é usada, porque para RPC, posso ter vários retornos para uma solicitação (o chamado modo de fluxo), mas não posso dizer que não há retorno direto sem solicitação, caso contrário, o lado do processamento do usuário será mais complicado e inconsistente. Exemplo de grpc usando o modo streaming:

stream, err := streamClient.Ping(ctx)
err = stream.Send(request)
for {
    
    
    response, err := stream.Recv()
}

Pode-se observar que o Server Push nunca foi uma tecnologia nova, pois esta função sempre esteve disponível no TCP. O que nos falta na verdade é apenas a especificação da operação na camada de aplicação.

afinal

Desde o nascimento do epoll até a solução do problema do Server Push, não é difícil perceber que a chamada nova tecnologia não é uma tecnologia de uma perspectiva mais macro, mas apenas algumas mudanças no consenso acordado. Mas é essa mudança de consenso que pode levar décadas.

A civilização é construída sobre o consenso, assim como a tecnologia. O progresso da civilização depende da quebra do velho consenso de que as mulheres são incapazes de virtude, assim como o progresso da tecnologia.

おすすめ

転載: blog.csdn.net/kalvin_y_liu/article/details/130004037
RPC