写点什么

浅谈 WEBrick 的多线程模型

  • 2019-12-05
  • 本文字数:6447 字

    阅读完需:约 21 分钟

浅谈 WEBrick 的多线程模型


这篇文章会介绍在开发环境中最常用的应用容器 WEBrick 的实现原理,除了通过源代码分析之外,我们也会介绍它的 IO 模型以及一些特点。


在 GitHub 上,WEBrick 从 2003 年的六月份就开始开发了,有着十几年历史的 WEBrick 的实现非常简单,总共只有 4000 多行的代码:


Ruby


$ loc_counter .40 files processedTotal     6918 linesEmpty     990 linesComments  1927 linesCode      4001 lines
复制代码

WEBrick 的实现

由于 WEBrick 是 Rack 中内置的处理器,所以与 Unicorn 和 Puma 这种第三方开发的 webserver 不同,WEBrick 的处理器是在 Rack 中实现的,而 WEBrick 的运行也都是从这个处理器的开始的。


Ruby


module Rack  module Handler    class WEBrick < ::WEBrick::HTTPServlet::AbstractServlet      def self.run(app, options={})        environment  = ENV['RACK_ENV'] || 'development'        default_host = environment == 'development' ? 'localhost' : nil
options[:BindAddress] = options.delete(:Host) || default_host options[:Port] ||= 8080 @server = ::WEBrick::HTTPServer.new(options) @server.mount "/", Rack::Handler::WEBrick, app yield @server if block_given? @server.start end end endend
复制代码


我们在上一篇文章 谈谈 Rack 协议与实现 中介绍 Rack 的实现原理时,最终调用了上述方法,从这里开始大部分的实现都与 WEBrick 有关了。


在这里,你可以看到方法会先处理传入的参数比如:地址、端口号等等,在这之后会使用 WEBrick 提供的 HTTPServer 来处理 HTTP 请求,调用 mount 在根路由上挂载应用和处理器 Rack::Handler::WEBrick 接受请求,最后执行 #start 方法启动服务器。

初始化服务器

HTTPServer 的初始化分为两个阶段,一部分是 HTTPServer 的初始化,另一部分调用父类的 initialize 方法,在自己构造器中,会配置当前服务器能够处理的 HTTP 版本并初始化新的 MountTable 实例:


Ruby


From: lib/webrick/httpserver.rb @ line 46:Owner: #<Class:WEBrick::HTTPServer>
def initialize(config={}, default=Config::HTTP) super(config, default) @http_version = HTTPVersion::convert(@config[:HTTPVersion])
@mount_tab = MountTable.new if @config[:DocumentRoot] mount("/", HTTPServlet::FileHandler, @config[:DocumentRoot], @config[:DocumentRootOptions]) end
unless @config[:AccessLog] @config[:AccessLog] = [ [ $stderr, AccessLog::COMMON_LOG_FORMAT ], [ $stderr, AccessLog::REFERER_LOG_FORMAT ] ] end
@virtual_hosts = Array.newend
复制代码


在父类 GenericServer 中初始化了用于监听端口号的 Socket 连接:


Ruby


From: lib/webrick/server.rb @ line 185:Owner: #<Class:WEBrick::GenericServer>
def initialize(config={}, default=Config::General) @config = default.dup.update(config) @status = :Stop
@listeners = [] listen(@config[:BindAddress], @config[:Port]) if @config[:Port] == 0 @config[:Port] = @listeners[0].addr[1] endend
复制代码


每一个服务器都会在初始化的时候创建一系列的 listener 用于监听地址和端口号组成的元组,其内部调用了 Utils 模块中定义的方法:


Ruby


From: lib/webrick/server.rb @ line 127:Owner: #<Class:WEBrick::GenericServer>
def listen(address, port) @listeners += Utils::create_listeners(address, port)end
From: lib/webrick/utils.rb @ line 61:Owner: #<Class:WEBrick::Utils>
def create_listeners(address, port) sockets = Socket.tcp_server_sockets(address, port) sockets = sockets.map {|s| s.autoclose = false ts = TCPServer.for_fd(s.fileno) s.close ts } return socketsendmodule_function :create_listeners
复制代码


.create_listeners 方法中调用了 .tcp_server_sockets 方法由于初始化一组 Socket 对象,最后得到一个数组的 TCPServer 实例。

挂载应用

在使用 WEBrick 启动服务的时候,第二步就是将处理器和 Rack 应用挂载到根路由下:


Ruby


@server.mount "/", Rack::Handler::WEBrick, app
复制代码


#mount 方法其实是一个比较简单的方法,因为我们在构造器中已经初始化了 MountTable 对象,所以这一步只是将传入的多个参数放到这个表中:


Ruby


From: lib/webrick/httpserver.rb @ line 155:Owner: WEBrick::HTTPServer
def mount(dir, servlet, *options) @mount_tab[dir] = [ servlet, options ]end
复制代码


MountTable 是一个包含从路由到 Rack 处理器一个 App 的映射表:



当执行了 MountTable#compile 方法时,上述的对象就会将表中的所有键按照加入的顺序逆序拼接成一个如下的正则表达式用来匹配传入的路由:


Ruby


^(/|/admin|/user)(?=/|$)
复制代码


上述正则表达式在使用时如果匹配到了指定的路由就会返回 $&$' 两个部分,前者表示整个匹配的文本,后者表示匹配文本后面的字符串。

启动服务器

Rack::Handler::WEBrick 中的 .run 方法先初始化了服务器,将处理器和应用挂载到了根路由上,在最后执行 #start 方法启动服务器:


Ruby


From: lib/webrick/server.rb @ line 152:Owner: WEBrick::GenericServer
def start(&block) raise ServerError, "already started." if @status != :Stop
@status = :Running begin while @status == :Running begin if svrs = IO.select([*@listeners], nil, nil, 2.0) svrs[0].each{ |svr| sock = accept_client(svr) start_thread(sock, &block) } end rescue Errno::EBADF, Errno::ENOTSOCK, IOError, StandardError => ex rescue Exception => ex raise end end ensure cleanup_listener @status = :Stop endend
复制代码


由于原方法的实现比较复杂不容易阅读,在这里对方法进行了简化,省略了向 logger 中输出内容、处理服务的关闭以及执行回调等功能。


我们可以理解为上述方法通过 .select 方法对一组 Socket 进行监听,当有消息需要处理时就依次执行 #accept_client#start_thread 两个方法处理来自客户端的请求:


Ruby


From: lib/webrick/server.rb @ line 254:Owner: WEBrick::GenericServer
def accept_client(svr) sock = nil begin sock = svr.accept sock.sync = true Utils::set_non_blocking(sock) rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINVAL rescue StandardError => ex msg = "#{ex.class}: #{ex.message}\n\t#{ex.backtrace[0]}" @logger.error msg end return sockend
复制代码


WEBrick 在 #accept_client 方法中执行了 #accept 方法来得到一个 TCP 客户端 Socket,同时会通过 set_non_blocking 将该 Socket 变成非阻塞的,最后在方法末尾返回创建的 Socket。


#start_thread 方法中会开启一个新的线程,并在新的线程中执行 #run 方法来处理请求:


Ruby


From: lib/webrick/server.rb @ line 278:Owner: WEBrick::GenericServer
def start_thread(sock, &block) Thread.start { begin Thread.current[:WEBrickSocket] = sock run(sock) rescue Errno::ENOTCONN, ServerError, Exception ensure Thread.current[:WEBrickSocket] = nil sock.close end }end
复制代码

处理请求

所有的请求都不会由 GenericServer 这个通用的服务器来处理,它只处理通用的逻辑,对于 HTTP 请求的处理都是在 HTTPServer#run 中完成的:


Ruby


From: lib/webrick/httpserver.rb @ line 69:Owner: WEBrick::HTTPServer
def run(sock) while true res = HTTPResponse.new(@config) req = HTTPRequest.new(@config) server = self begin timeout = @config[:RequestTimeout] while timeout > 0 break if sock.to_io.wait_readable(0.5) break if @status != :Running timeout -= 0.5 end raise HTTPStatus::EOFError if timeout <= 0 || @status != :Running raise HTTPStatus::EOFError if sock.eof? req.parse(sock) res.request_method = req.request_method res.request_uri = req.request_uri res.request_http_version = req.http_version self.service(req, res) rescue HTTPStatus::EOFError, HTTPStatus::RequestTimeout, HTTPStatus::Error => ex res.set_error(ex) rescue HTTPStatus::Status => ex res.status = ex.code rescue StandardError => ex res.set_error(ex, true) ensure res.send_response(sock) if req.request_line end break if @http_version < "1.1" endend
复制代码


对 HTTP 协议了解的读者应该能从上面的代码中看到很多与 HTTP 协议相关的东西,比如 HTTP 的版本号、方法、URL 等等,上述方法总共做了三件事情,等待监听的 Socket 变得可读,执行 #parse 方法解析 Socket 上的数据,通过 #service 方法完成处理请求的响应,首先是对 Socket 上的数据进行解析:


Ruby


From: lib/webrick/httprequest.rb @ line 192:Owner: WEBrick::HTTPRequest
def parse(socket=nil) @socket = socket begin @peeraddr = socket.respond_to?(:peeraddr) ? socket.peeraddr : [] @addr = socket.respond_to?(:addr) ? socket.addr : [] rescue Errno::ENOTCONN raise HTTPStatus::EOFError end
read_request_line(socket) if @http_version.major > 0 # ... end return if @request_method == "CONNECT" return if @unparsed_uri == "*"
begin setup_forwarded_info @request_uri = parse_uri(@unparsed_uri) @path = HTTPUtils::unescape(@request_uri.path) @path = HTTPUtils::normalize_path(@path) @host = @request_uri.host @port = @request_uri.port @query_string = @request_uri.query @script_name = "" @path_info = @path.dup rescue raise HTTPStatus::BadRequest, "bad URI `#{@unparsed_uri}'." end
if /close/io =~ self["connection"] # deal with keep alive endend
复制代码


由于 HTTP 协议本身就比较复杂,请求中包含的信息也非常多,所以在这里用于解析 HTTP 请求的代码也很多,想要了解 WEBrick 是如何解析 HTTP 请求的可以看 httprequest.rb 文件中的代码,在处理了 HTTP 请求之后,就开始执行 #service 响应该 HTTP 请求了:


Ruby


From: lib/webrick/httpserver.rb @ line 125:Owner: WEBrick::HTTPServer
def service(req, res) servlet, options, script_name, path_info = search_servlet(req.path) raise HTTPStatus::NotFound, "`#{req.path}' not found." unless servlet req.script_name = script_name req.path_info = path_info si = servlet.get_instance(self, *options) si.service(req, res)end
复制代码


在这里我们会从上面提到的 MountTable 中找出在之前注册的处理器 handler 和 Rack 应用:


Ruby


From: lib/webrick/httpserver.rb @ line 182:Owner: WEBrick::HTTPServer
def search_servlet(path) script_name, path_info = @mount_tab.scan(path) servlet, options = @mount_tab[script_name] if servlet [ servlet, options, script_name, path_info ] endend
复制代码


得到了处理器 handler 之后,通过 .get_instance 方法创建一个新的实例,这个方法在大多数情况下等同于初始化方法 .new,随后调用了该处理器 Rack::WEBrick::Handler#service 方法,该方法是在 rack 工程中定义的:


Ruby


From: rack/lib/handler/webrick.rb @ line 57:Owner: Rack::Handler::WEBrick
def service(req, res) res.rack = true env = req.meta_vars env.delete_if { |k, v| v.nil? }
env.update( # ... RACK_URL_SCHEME => ["yes", "on", "1"].include?(env[HTTPS]) ? "https" : "http", # ... )
status, headers, body = @app.call(env) begin res.status = status.to_i headers.each { |k, vs| # ... }
body.each { |part| res.body << part } ensure body.close if body.respond_to? :close endend
复制代码


由于上述方法也涉及了非常多 HTTP 协议的实现细节所以很多过程都被省略了,在上述方法中,我们先构建了应用的输入 env 哈希变量,然后通过执行 #call 方法将控制权交给 Rack 应用,最后获得一个由 statusheadersbody 组成的三元组;在接下来的代码中,分别对这三者进行处理,为这次请求『填充』一个完成的 HTTP 请求。


到这里,最后由 WEBrick::HTTPServer#run 方法中的 ensure block 来结束整个 HTTP 请求的处理:


Ruby


From: lib/webrick/httpserver.rb @ line 69:Owner: WEBrick::HTTPServer
def run(sock) while true res = HTTPResponse.new(@config) req = HTTPRequest.new(@config) server = self begin # ... ensure res.send_response(sock) if req.request_line end break if @http_version < "1.1" endend
复制代码


#send_reponse 方法中,分别执行了 #send_header#send_body 方法向当前的 Socket 中发送 HTTP 响应中的数据:


Ruby


From: lib/webrick/httpresponse @ line 205:Owner: WEBrick::HTTPResponse
def send_response(socket) begin setup_header() send_header(socket) send_body(socket) rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN => ex @logger.debug(ex) @keep_alive = false rescue Exception => ex @logger.error(ex) @keep_alive = false endend
复制代码


所有向 Socket 中写入数据的工作最终都会由 #_write_data 这个方法来处理,将数据全部写入 Socket 中:


Ruby


From: lib/webrick/httpresponse @ line 464:Owner: WEBrick::HTTPResponse
def _write_data(socket, data) socket << dataend
复制代码


从解析 HTTP 请求、调用 Rack 应用、创建 Response 到最后向 Socket 中写回数据,WEBrick 处理 HTTP 请求的部分就结束了。

I/O 模型

通过对 WEBrick 源代码的阅读,我们其实已经能够了解整个 webserver 的工作原理,当我们启动一个 WEBrick 服务时只会启动一个进程,该进程会在指定的 ip 和端口上使用 .select 监听来自用户的所有 HTTP 请求:



.select 接收到来自用户的请求时,会为每一个请求创建一个新的 Thread 并在新的线程中对 HTTP 请求进行处理。


由于 WEBrick 在运行时只会启动一个进程,并没有其他的守护进程,所以它不够健壮,不能在发生问题时重启持续对外界提供服务,再加上 WEBrick 确实历史比较久远,代码的风格也不是特别的优雅,还有普遍知道的内存泄漏以及 HTTP 解析的问题,所以在生产环境中很少被使用。


虽然 WEBrick 有一些性能问题,但是作为 Ruby 自带的默认 webserver,在开发阶段使用 WEBrick 提供服务还是没有什么问题的。

总结

WEBrick 是 Ruby 社区中老牌的 webserver,虽然至今也仍然被广泛了解和使用,但是在生产环境中开发者往往会使用更加稳定的 Unicorn 和 Puma 代替它,我们选择在这个系列的文章中介绍它很大原因就是 WEBrick 的源代码与实现足够简单,我们很快就能了解一个 webserver 到底具备那些功能,在接下来的文章中我们就可以分析更加复杂的 webserver、了解更复杂的 IO 模型与实现了。

相关文章


本文转载自 Draveness 技术博客。


原文链接:https://draveness.me/rack-webrick


2019-12-05 18:14710

评论

发布
暂无评论
发现更多内容

架构训练营-week13-作业1

于成龙

架构训练营

看了这个你还不懂mybatis缓存,单独找我

田维常

mybatis

科普:区块链中的公链和私链的有什么区别?

CECBC

公有链 私有链

系统性能优化案例(秒杀系统)

积极&丧

联邦学习与安全多方计算

DataFunTalk

学习 AI

LeetCode题解:18. 四数之和,哈希表,JavaScript,详细注释

Lee Chen

算法 大前端 LeetCode

架构师训练营第 13 周课后练习

薛凯

智慧城市管理平台,多规合一平台搭建解决方案

t13823115967

智慧城市

北纬37°的浪漫与理性,穿越千年的信息之变

脑极体

可视化指挥调度平台搭建,应急指挥调度监控中心建设方案

t13823115967

可视化 指挥调度

[架构师训练营第 1 期] 第 13 周学习总结

猫切切切切切

极客大学架构师训练营

架构训练营-week13-总结

于成龙

在wildfly中使用SAML协议连接keycloak

程序那些事

程序那些事 SAML openid SSO wildfly

JVM 垃圾回收原理简述

Mars

从“断臂求生”到一骑绝尘,航运巨头马士基如何利用区块链技术力挽狂澜?

CECBC

航运

面试阿里被质问:ConcurrentHashMap线程安全吗

Java架构师迁哥

从月薪3K的Java菜鸟,到年薪80W的Java架构师,靠这份文档,让我学习像打怪升级一样简单!

Java架构之路

Java 程序员 架构 面试 编程语言

列举出常见的Java面试题100+,我靠这个在十月拿到了阿里的offer

Java架构之路

Java 程序员 架构 面试 编程语言

工厂模式学习

en

设计模式 工厂模式 Go 语言

java代码优化技巧及原理

积极&丧

Java架构速成笔记:五大专题,1345页考点 看完直接收获腾讯、京东、滴滴offer

比伯

Java 编程 架构 面试 计算机

阿里P8传授给小老弟的Java面试宝典,竟让让小弟也拿到了P8的offer,傻眼了

Java架构之路

Java 程序员 架构 面试 编程语言

阿里大牛亲自总结整理的一线互联网公司面试真题及面经(阿里、网易、字节)

Java架构之路

Java 程序员 架构 面试 编程语言

性能优化总结(三)

Mars

万字庖解区块链跨链技术

CECBC

区块链

架构师训练营第 13 周作业

netspecial

极客大学架构师训练营

架构师训练营第九周作业

丁乐洪

天下武功,唯”拆“不破之架构篇一 | 技术人应知的创新思维模型 (7)

Alan

架构 个人成长 思维 技术人应知的创新思维模型 28天写作

泡泡玛特市值千亿背后,我们为什么会为盲盒买单?

脑极体

mybatis一级缓存是啥?5个面试题回答你

田维常

mybatis

mybatis二级缓存,7个问题拿下它

田维常

mybatis

浅谈 WEBrick 的多线程模型_语言 & 开发_Draveness_InfoQ精选文章