소개 | 애플리케이션의 입출력(I/O) 모델을 이해한다는 것은 계획된 처리 로드와 잔인한 실제 사용 시나리오 간의 차이를 의미합니다. 애플리케이션이 상대적으로 작고 높은 로드를 제공하지 않는 경우에는 영향이 거의 없을 수 있습니다. 그러나 애플리케이션의 로드가 점차 증가함에 따라 잘못된 I/O 모델을 채택하면 많은 함정과 상처를 남길 수 있습니다. |
여러 솔루션이 있는 대부분의 시나리오와 마찬가지로 중요한 것은 어느 솔루션이 더 나은지보다는 절충 방법을 이해하는 것입니다. I/O 환경을 둘러보고 여기서 무엇을 훔칠 수 있는지 살펴보겠습니다.
이 기사에서는 Node, Java, Go 및 PHP를 각각 Apache와 비교하고 이러한 다양한 언어가 I/O를 모델링하는 방법, 각 모델의 장점과 단점을 논의하고 몇 가지 벤치마크 결론을 도출합니다. 다음 웹 애플리케이션의 I/O 성능이 걱정된다면 올바른 기사를 찾으신 것입니다.
I/O 기본 사항: 빠른 검토I/O와 밀접하게 관련된 요소를 이해하려면 먼저 운영 체제의 기본 개념을 검토해야 합니다. 이러한 개념의 대부분을 직접 다루지는 않겠지만 애플리케이션의 런타임 환경을 통해 간접적으로 다루어 왔습니다. 그리고 악마는 디테일에 있습니다.
시스템 호출먼저 다음과 같이 설명할 수 있는 시스템 호출이 있습니다.
알겠습니다. 위에서는 시스템 호출이 차단되고 있다고 말했습니다. 일반적으로 이는 사실입니다. 그러나 일부 호출은 "비차단"으로 분류됩니다. 즉, 커널이 요청을 수신하고 이를 대기열이나 버퍼 어딘가에 넣은 다음 실제 I/O 호출을 기다리지 않고 즉시 반환합니다. 따라서 요청을 대기열에 넣을 만큼 매우 짧은 시간 동안만 "차단"됩니다.
설명을 돕기 위한 몇 가지 예(Linux 시스템 호출)는 다음과 같습니다. -read()는 차단 호출입니다. 읽은 데이터를 저장하기 위해 파일 핸들과 버퍼를 전달한 다음 데이터가 반환될 때 호출이 반환됩니다. 준비가 된. 이 접근 방식은 우아함과 단순함이라는 장점이 있습니다. -epoll_create() , epoll_ctl() 및 epoll_wait()은 각각 수신할 핸들 세트를 생성하고 해당 세트에서 핸들을 추가/제거한 다음 활동이 있을 때까지 기다릴 수 있는 호출입니다. 방금 차단되었습니다. 이를 통해 스레드를 통해 일련의 I/O 작업을 효율적으로 제어할 수 있습니다. 이러한 기능이 필요한 경우에는 유용하지만 보시다시피 사용하기가 꽤 복잡합니다.
여기서 타이밍 차이의 크기 순서를 이해하는 것이 중요합니다. CPU 코어가 최적화 없이 3GHz에서 실행되는 경우 초당 30억 개의 루프(또는 나노초당 3개의 루프)를 실행합니다. 비차단 시스템 호출은 완료하는 데 약 10나노초 또는 "상대적으로 몇 나노초" 정도의 시간이 걸릴 수 있습니다. 네트워크를 통해 정보를 수신하는 통화를 차단하는 데는 더 많은 시간이 걸릴 수 있습니다(예: 200밀리초(0.2초)). 예를 들어 비차단 호출에 20나노초가 걸렸다고 가정하면 차단 호출에는 200,000,000나노초가 걸렸습니다. 호출을 차단하는 경우 프로그램은 천만 배 더 오래 기다립니다.
커널은 차단 I/O("네트워크 연결에서 읽고 데이터 제공")와 비차단 I/O("이 네트워크 연결에 새 데이터가 있으면 알려주세요")라는 두 가지 방법을 제공합니다. 어떤 메커니즘을 사용하느냐에 따라 해당 호출 프로세스의 차단 시간은 분명히 다릅니다.
예약세 번째로 중요한 것은 많은 수의 스레드나 프로세스가 차단되기 시작할 때 어떻게 해야 하는지입니다.
우리의 목적에 따라 스레드와 프로세스 사이에는 큰 차이가 없습니다. 실제로 실행과 관련하여 가장 눈에 띄는 차이점은 스레드가 동일한 메모리를 공유하는 반면 각 프로세스는 자체 메모리 공간을 가지므로 별도의 프로세스가 많은 양의 메모리를 차지하는 경우가 많다는 것입니다. 그러나 스케줄링에 대해 이야기할 때 이는 궁극적으로 각 이벤트가 사용 가능한 CPU 코어에서 실행 시간을 확보해야 하는 이벤트 목록(스레드 및 프로세스 모두)으로 귀결됩니다. 300개의 스레드가 실행 중이고 8개의 코어에서 실행 중인 경우 짧은 시간 동안 각 코어를 실행한 후 다음 스레드로 전환하여 각 스레드가 무언가를 얻을 수 있도록 해당 시간을 분산시켜야 합니다. 이는 CPU가 실행 중인 한 스레드/프로세스에서 다음 스레드/프로세스로 전환할 수 있도록 하는 "컨텍스트 전환"을 통해 달성됩니다.
이러한 컨텍스트 스위치에는 비용이 듭니다. 시간이 좀 걸립니다. 빠르면 아마도 100나노초 미만일 것입니다. 그러나 구현 세부 사항, 프로세서 속도/아키텍처, CPU 캐시 등에 따라 1000나노초 이상이 걸리는 경우도 많습니다.
스레드(또는 프로세스)가 많을수록 컨텍스트 스위치도 많아집니다. 수천 개의 스레드에 대해 이야기하고 각 스위치에 수백 나노초가 걸리면 속도가 매우 느려질 것입니다.
그러나 비차단 호출은 본질적으로 커널에 "새로운 데이터가 있거나 이러한 연결 중 하나에 이벤트가 있을 때만 전화해 주세요"라고 지시합니다. 이러한 비차단 호출은 대규모 I/O 로드를 효율적으로 처리하고 컨텍스트 전환을 줄이도록 설계되었습니다.
아직도 이 글을 읽고 계시나요? 이제 재미있는 부분이 왔습니다. 일부 유창한 언어가 이러한 도구를 어떻게 사용하는지 살펴보고 사용 용이성과 성능 간의 균형에 대한 결론을 도출해 보겠습니다. 그리고 기타 흥미로운 의견도 있습니다.
이 게시물에 표시된 예제는 사소하지만(불완전하여 코드의 관련 부분만 표시함), 데이터베이스 액세스, 외부 캐싱 시스템(memcache 등 모두) 및 I/O가 필요한 예제입니다. 표시된 예와 동일한 영향을 미치는 일부 기본 I/O 작업을 수행합니다. 마찬가지로 I/O가 "차단"(PHP, Java)으로 설명되는 상황의 경우 HTTP 요청 및 응답의 읽기 및 쓰기 자체가 호출을 차단하는 것입니다. 다시 한번 더 많은 I/O가 시스템 O 및 그에 수반되는 시스템에 숨겨져 있습니다. 성능 문제를 고려해야 합니다.
프로젝트에 적합한 프로그래밍 언어를 선택할 때 고려해야 할 요소가 많이 있습니다. 성능만 고려한다면 고려해야 할 요소가 훨씬 더 많습니다. 그러나 프로그램이 주로 I/O에 바인딩되어 있고 I/O 성능이 프로젝트에 중요하다는 것이 우려된다면 다음 사항을 알아야 합니다. "간단하게 유지"하는 접근 방식: PHP.
90년대에는 많은 사람들이 컨버스 신발을 신고 Perl로 CGI 스크립트를 썼습니다. 그러다가 PHP가 등장했고 많은 사람들이 즐겨 사용하게 되었는데, 덕분에 동적 웹 페이지를 더 쉽게 만들 수 있게 되었습니다.
PHP에서 사용하는 모델은 매우 간단합니다. 몇 가지 변형이 있지만 기본적으로 PHP 서버는 다음과 같습니다.
HTTP 요청은 사용자의 브라우저에서 발생하며 Apache 웹 서버에 액세스합니다. Apache는 각 요청에 대해 별도의 프로세스를 생성하고 이를 일부 최적화와 함께 재사용하여 실행에 필요한 횟수를 최소화합니다(프로세스 생성은 상대적으로 느립니다). Apache는 PHP를 호출하여 디스크에서 해당 .php 파일을 실행하도록 지시합니다. PHP 코드가 실행되고 일부 차단 I/O 호출이 발생합니다. file_get_contents()가 PHP에서 호출되면 백그라운드에서 read() 시스템 호출이 트리거되고 결과가 반환될 때까지 기다립니다.
물론 실제 코드는 페이지에 삽입되어 있으며 작업이 차단됩니다.
으아아아시스템과 통합하는 방법은 다음과 같습니다.
相当简单:一个请求,一个进程。I/O是阻塞的。优点是什么呢?简单,可行。那缺点是什么呢?同时与20,000个客户端连接,你的服务器就挂了。由于内核提供的用于处理大容量I/O(epoll等)的工具没有被使用,所以这种方法不能很好地扩展。更糟糕的是,为每个请求运行一个单独的过程往往会使用大量的系统资源,尤其是内存,这通常是在这样的场景中遇到的第一件事情。
注意:Ruby使用的方法与PHP非常相似,在广泛而普遍的方式下,我们可以将其视为是相同的。
多线程的方式:Java所以就在你买了你的第一个域名的时候,Java来了,并且在一个句子之后随便说一句“dot com”是很酷的。而Java具有语言内置的多线程(特别是在创建时),这一点非常棒。
大多数Java网站服务器通过为每个进来的请求启动一个新的执行线程,然后在该线程中最终调用作为应用程序开发人员的你所编写的函数。
在Java的Servlet中执行I/O操作,往往看起来像是这样:
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 阻塞的文件I/O InputStream fileIs = new FileInputStream("/path/to/file"); // 阻塞的网络I/O URLConnection urlConnection = (new URL("https://example.com/example-microservice")).openConnection(); InputStream netIs = urlConnection.getInputStream(); // 更多阻塞的网络I/O out.println("..."); }
由于我们上面的doGet 方法对应于一个请求并且在自己的线程中运行,而不是每次请求都对应需要有自己专属内存的单独进程,所以我们会有一个单独的线程。这样会有一些不错的优点,例如可以在线程之间共享状态、共享缓存的数据等,因为它们可以相互访问各自的内存,但是它如何与调度进行交互的影响,仍然与前面PHP例子中所做的内容几乎一模一样。每个请求都会产生一个新的线程,而在这个线程中的各种I/O操作会一直阻塞,直到这个请求被完全处理为止。为了最小化创建和销毁它们的成本,线程会被汇集在一起,但是依然,有成千上万个连接就意味着成千上万个线程,这对于调度器是不利的。
一个重要的里程碑是,在Java 1.4 版本(和再次显著升级的1.7 版本)中,获得了执行非阻塞I/O调用的能力。大多数应用程序,网站和其他程序,并没有使用它,但至少它是可获得的。一些Java网站服务器尝试以各种方式利用这一点; 然而,绝大多数已经部署的Java应用程序仍然如上所述那样工作。
Java让我们更进了一步,当然对于I/O也有一些很好的“开箱即用”的功能,但它仍然没有真正解决问题:当你有一个严重I/O绑定的应用程序正在被数千个阻塞线程狂拽着快要坠落至地面时怎么办。
作为一等公民的非阻塞I/O:Node当谈到更好的I/O时,Node.js无疑是新宠。任何曾经对Node有过最简单了解的人都被告知它是“非阻塞”的,并且它能有效地处理I/O。在一般意义上,这是正确的。但魔鬼藏在细节中,当谈及性能时这个巫术的实现方式至关重要。
本质上,Node实现的范式不是基本上说“在这里编写代码来处理请求”,而是转变成“在这里写代码开始处理请求”。每次你都需要做一些涉及I/O的事情,发出请求或者提供一个当完成时Node会调用的回调函数。
在求中进行I/O操作的典型Node代码,如下所示:
http.createServer(function(request, response) { fs.readFile('/path/to/file', 'utf8', function(err, data) { response.end(data); }); });
可以看到,这里有两个回调函数。第一个会在请求开始时被调用,而第二个会在文件数据可用时被调用。
这样做的基本上给了Node一个在这些回调函数之间有效地处理I/O的机会。一个更加相关的场景是在Node中进行数据库调用,但我不想再列出这个烦人的例子,因为它是完全一样的原则:启动数据库调用,并提供一个回调函数给Node,它使用非阻塞调用单独执行I/O操作,然后在你所要求的数据可用时调用回调函数。这种I/O调用队列,让Node来处理,然后获取回调函数的机制称为“事件循环”。它工作得非常好。
然而,这个模型中有一道关卡。在幕后,究其原因,更多是如何实现JavaScript V8 引擎(Chrome的JS引擎,用于Node)1,而不是其他任何事情。你所编写的JS代码全部都运行在一个线程中。思考一下。这意味着当使用有效的非阻塞技术执行I/O时,正在进行CPU绑定操作的JS可以在运行在单线程中,每个代码块阻塞下一个。 一个常见的例子是循环数据库记录,在输出到客户端前以某种方式处理它们。以下是一个例子,演示了它如何工作:
var handler = function(request, response) { connection.query('SELECT ...', function (err, rows) { if (err) { throw err }; for (var i = 0; i < rows.length; i++) { // 对每一行纪录进行处理 } response.end(...); // 输出结果 }) };
虽然Node确实可以有效地处理I/O,但上面的例子中的for 循环使用的是在你主线程中的CPU周期。这意味着,如果你有10,000个连接,该循环有可能会让你整个应用程序慢如蜗牛,具体取决于每次循环需要多长时间。每个请求必须分享在主线程中的一段时间,一次一个。
这个整体概念的前提是I/O操作是最慢的部分,因此最重要是有效地处理这些操作,即使意味着串行进行其他处理。这在某些情况下是正确的,但不是全都正确。
另一点是,虽然这只是一个意见,但是写一堆嵌套的回调可能会令人相当讨厌,有些人认为它使得代码明显无章可循。在Node代码的深处,看到嵌套四层、嵌套五层、甚至更多层级的嵌套并不罕见。
我们再次回到了权衡。如果你主要的性能问题在于I/O,那么Node模型能很好地工作。然而,它的阿喀琉斯之踵(
真正的非阻塞:Go在进入Go这一章节之前,我应该披露我是一名Go粉丝。我已经在许多项目中使用Go,是其生产力优势的公开支持者,并且在使用时我在工作中看到了他们。
也就是说,我们来看看它是如何处理I/O的。Go语言的一个关键特性是它包含自己的调度器。并不是每个线程的执行对应于一个单一的OS线程,Go采用的是“goroutines”这一概念。Go运行时可以将一个goroutine分配给一个OS线程并使其执行,或者把它挂起而不与OS线程关联,这取决于goroutine做的是什么。来自Go的HTTP服务器的每个请求都在单独的Goroutine中处理。
此调度器工作的示意图,如下所示:
这是通过在Go运行时的各个点来实现的,通过将请求写入/读取/连接/等实现I/O调用,让当前的goroutine进入睡眠状态,当可采取进一步行动时用信息把goroutine重新唤醒。
实际上,除了回调机制内置到I/O调用的实现中并自动与调度器交互外,Go运行时做的事情与Node做的事情并没有太多不同。它也不受必须把所有的处理程序代码都运行在同一个线程中这一限制,Go将会根据其调度器的逻辑自动将Goroutine映射到其认为合适的OS线程上。最后代码类似这样:
func ServeHTTP(w http.ResponseWriter, r *http.Request) { // 这里底层的网络调用是非阻塞的 rows, err := db.Query("SELECT ...") for _, row := range rows { // 处理rows // 每个请求在它自己的goroutine中 } w.Write(...) // 输出响应结果,也是非阻塞的 }
正如你在上面见到的,我们的基本代码结构像是更简单的方式,并且在背后实现了非阻塞I/O。
在大多数情况下,这最终是“两个世界中最好的”。非阻塞I/O用于全部重要的事情,但是你的代码看起来像是阻塞,因此往往更容易理解和维护。Go调度器和OS调度器之间的交互处理了剩下的部分。这不是完整的魔法,如果你建立的是一个大型的系统,那么花更多的时间去理解它工作原理的更多细节是值得的; 但与此同时,“开箱即用”的环境可以很好地工作和很好地进行扩展。
Go可能有它的缺点,但一般来说,它处理I/O的方式不在其中。
谎言,诅咒的谎言和基准对这些各种模式的上下文切换进行准确的定时是很困难的。也可以说这对你来没有太大作用。所以取而代之,我会给出一些比较这些服务器环境的HTTP服务器性能的基准。请记住,整个端对端的HTTP请求/响应路径的性能与很多因素有关,而这里我放在一起所提供的数据只是一些样本,以便可以进行基本的比较。
对于这些环境中的每一个,我编写了适当的代码以随机字节读取一个64k大小的文件,运行一个SHA-256哈希N次(N在URL的查询字符串中指定,例如.../test.php?n=100 ),并以十六进制形式打印生成的散列。我选择了这个示例,是因为使用一些一致的I/O和一个受控的方式增加CPU使用率来运行相同的基准测试是一个非常简单的方式。
환경 사용에 관한 자세한 내용은 벤치마크 포인트를 참조하세요.
먼저, 동시성이 낮은 몇 가지 예를 살펴보겠습니다. 2000번의 반복, 300개의 동시 요청을 실행하고 요청당 한 번만 해싱(N = 1)하면 다음과 같은 결과를 얻습니다.
시간은 모든 동시 요청 중에서 요청을 완료하는 데 걸리는 평균 밀리초입니다. 낮을수록 좋습니다.
단지 하나의 그래프에서 결론을 도출하기는 어렵지만 제 생각에는 연결성 및 계산과 관련된 것으로 보이며 시간이 언어 자체의 일반적인 실행과 더 관련이 있으므로 I/O가 더 많다는 것을 알 수 있습니다. "스크립팅 언어"(임의 입력, 동적으로 해석됨)로 간주되는 언어는 가장 느리게 수행됩니다.
그러나 N을 1000으로 늘리고 여전히 300개의 동시 요청이 있는 경우(부하는 동일하지만 해시 반복이 100배 증가함(CPU 부하가 크게 증가))
시간은 모든 동시 요청 중에서 요청을 완료하는 데 걸리는 평균 밀리초입니다. 낮을수록 좋습니다.
갑자기 각 요청의 CPU 집약적인 작업이 서로 차단되면서 Node의 성능이 크게 떨어졌습니다. 흥미롭게도 이 테스트에서 PHP는 다른 언어에 비해 훨씬 더 나은 성능을 발휘했으며 Java를 능가했습니다. (SHA-256 구현이 C로 작성된 PHP에서는 이번에는 1000번의 해시 반복을 수행하므로 이 루프에서 실행 경로에 더 많은 시간이 걸린다는 점에 주목할 가치가 있습니다.)
이제 5000개의 동시 연결(및 N = 1) 또는 그에 가까운 연결을 시도해 보겠습니다. 불행하게도 대부분의 이러한 환경에서는 실패율이 크지 않습니다. 이 차트에서는 초당 총 요청 수에 중점을 둡니다. 높을수록 좋습니다:
초당 총 요청 수입니다. 높을수록 좋습니다.
이 사진은 전혀 다르게 보이네요. 이는 추측이지만 높은 연결 볼륨, 연결당 새 프로세스 생성과 관련된 오버헤드, PHP + Apache와 관련된 추가 메모리가 PHP 성능을 제한하는 주요 요인인 것으로 보입니다. 분명히 Go가 승자이고 Java와 Node가 그 뒤를 따르고 마지막으로 PHP가 승리합니다.
결론요약하자면, 언어가 발전함에 따라 대량의 I/O를 처리하는 대규모 애플리케이션을 위한 솔루션도 발전한다는 것은 분명합니다.
공정성을 위해 이 기사의 설명은 잠시 제쳐두고, PHP와 Java에는 웹 애플리케이션에서 사용할 수 있는 비차단 I/O 구현이 있습니다. 그러나 이러한 방법은 위의 방법만큼 일반적이지 않으며, 이 방법을 사용하여 서버를 유지하는데 수반되는 운영 오버헤드를 고려해야 합니다. 코드가 이러한 환경에 적합한 방식으로 구성되어야 한다는 점은 말할 것도 없고, "일반적인" PHP 또는 Java 웹 애플리케이션은 일반적으로 이러한 환경에서 큰 변화를 겪지 않습니다.
비교를 위해 성능과 사용 편의성에 영향을 미치는 몇 가지 중요한 요소만 고려하면 다음을 얻을 수 있습니다.
언어 | 스레드 또는 프로세스 | 비차단 I/O | 사용 용이성 | |
---|---|---|---|---|
PHP | 프로세스 | 아니요 | ||
자바 | 스레드 | 사용 가능 | 콜백이 필요합니다 | |
Node.js | 스레드 | 예 | 콜백이 필요합니다 | |
가자 | 스레드(고루틴) | 예 | 콜백이 필요하지 않습니다 |
스레드는 동일한 메모리 공간을 공유하지만 프로세스는 그렇지 않기 때문에 일반적으로 프로세스보다 메모리 효율성이 더 높습니다. Non-Blocking I/O 관련 요소와 결합하여 I/O 개선과 관련된 일반 시작으로 목록을 아래로 내리면 위에서 고려한 요소와 최소한 동일한 요소를 볼 수 있습니다. 위 게임들 중 승자를 꼽으라면 단연 바둑이다.
실제로 애플리케이션을 구축하기 위해 선택한 환경은 해당 환경에 대한 팀의 친숙도 및 달성할 수 있는 전반적인 생산성과 밀접한 관련이 있습니다. 따라서 모든 팀이 Node 또는 Go에서 바로 웹 애플리케이션 및 서비스 개발에 뛰어들어 개발을 시작하는 것은 타당하지 않을 수 있습니다. 실제로, 다른 언어 및/또는 다른 환경을 사용하지 않는 주요 이유로 개발자나 내부 팀과 친숙해지는 것이 종종 언급됩니다. 즉, 지난 15년 동안 시대는 극적으로 변했습니다.
위의 내용이 뒤에서 무슨 일이 일어나고 있는지 더 명확하게 파악하고 애플리케이션의 실제 확장성을 처리하는 방법에 대한 몇 가지 아이디어를 제공하는 데 도움이 되기를 바랍니다. 행복한 입력, 행복한 출력!
위 내용은 Node, PHP, Java, Go의 서버 I/O 성능 비교의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!