javascript - 闭包

闭包的概念

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。

也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

痛点

JS 的作用域分为函数作用域,全局作用域,块作用域。因 JS 的特殊性,在函数作用域内,外层作用域是无法获取到函数内的变量和函数的:

1
2
3
4
5
6
function result() {
var count = 0;
console.log(count)
}
// 无法访问 result 函数中的 count
console.log(count) // ReferenceError

解决方案

假如现在希望在函数外访问 result 函数内的 count 呢?可以做到吗?

可以!把代码改为如下形式就可以了

1
2
3
4
5
6
7
8
9
10
11
function result() {
var count = 0;
function getCount() {
console.log(count)
return count;
}
return getCount
}

var getCount = result()
getCount() // 0

上述代码,(count变量 + getCount函数)就形成了一个闭包,使得外层作用域可以通过某种形式进行读取

实际场景

  • 面向对象
    在 ES6 之前,JS 都没有“类”的语法的,导致很难写出面向对象的模式,但是,勉强可以借助闭包来实现:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    function Student() {
    var name = ''
    var age = 0
    function getName() {
    return name
    }
    function setName(newName) {
    name = newName
    }
    function sayHi() {
    return 'hi, ' + name
    }

    return {
    getName: getName,
    setName: setName,
    sayHi: sayHi
    }
    }

    var stu1 = Student()
    var stu2 = Student()
    stu1.setName('yigger01')
    stu2.setName('yigger02')
    console.log(stu1.sayHi()) // hi, yigger01
    console.log(stu2.sayHi()) // hi, yigger02

虽然并不是特别严谨,但总算是达到了隐藏变量和方法的效果,通过这样的“封装”,外部无法直接读取/写入 name 变量,只能通过暴露出来的方法进行变更。

另外,值得一提的是,在一些编程语言中,一个函数中的局部变量仅存在于此函数的执行期间。一旦 setName() 执行完毕,你可能会认为 name 变量将不能再被访问。然而,因为代码仍按预期运行,所以在 JavaScript 中情况显然与此不同。

闭包的循环“陷阱”

其实也称不上是陷阱,如果不了解闭包特性的人,踩坑一点也不奇怪,早期用 JQ 一顿写代码的时候也经常遇到过,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function caculate() {
var nums = ['one', 'two', 'three'];

for (var i = 0; i < nums.length; i++) {
setTimeout(function() {
console.log(i, nums[i])
}, 100)
}
}

caculate()
// 输出:
// 3, undefined
// 3, undefined
// 3, undefined

原因是什么?

因为 i 是用 var 声明的,由于变量的提升,实际上 i 是属于 caculate 的函数作用域,以上代码等同于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function caculate() {
var nums = ['one', 'two', 'three'];
var i
for (i = 0; i < nums.length; i++) {
setTimeout(function() {
console.log(i, nums[i])
}, 100)
}
}

caculate()

// 实际调用栈:
1 次循环:
i = 0, 由于 setTimeout,挂起执行`console.log(i, nums[i]`
2 次循环:
i = 1, 由于 setTimeout,挂起执行`console.log(i, nums[i]`
3 次循环:
i = 2, 由于 setTimeout,挂起执行`console.log(i, nums[i]`
// 执行完最后一次循环后,i++ ----> i = 3

由于以上代码是立即执行的,所以一瞬间就完成了,可以假设 需要 1ms 的时间

... 随后 cpu 喝了杯咖啡,过去了 99ms ...

随后开始执行定时器中的代码

由于闭包的特性,函数执行完成以后还保留着 i 的引用,而此时的 i = 3

所以,最后实际执行的结果:
console.log(3, nums[3]) // 3, undefined

解决方案

  • 方案 1:使用 let 关键字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function caculate() {
    var nums = ['one', 'two', 'three'];
    for (var i = 0; i < nums.length; i++) {
    let j = i // let 不会存在变量提升
    setTimeout(function() {
    console.log(j, nums[j])
    }, 100)
    }
    }
    caculate()
  • 方案 2:消除对 i 的引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function caculate() {
    var nums = ['one', 'two', 'three'];
    for (var i = 0; i < nums.length; i++) {
    // 匿名作用域,j 是 i 的复制,不是引用
    (function(j) {
    setTimeout(function() {
    console.log(j, nums[j])
    }, 100)
    })(i)
    }
    }
    caculate();

方案还有挺多,由于 es6 的基本都兼容主流浏览器了,一般都会写成方案 1

1
2
3
4
5
for (let i = 0; i < nums.length; i++) {
setTimeout(function() {
console.log(i, nums[i])
}, 100)
}

总结

  1. 闭包的形成条件:内部函数引用外部函数的变量/参数
  2. 闭包的结果:内部函数的使用外部函数的那些变量和参数仍然会保存
  3. 返回的函数并非孤立的函数,而是连同周围的环境打了一个包,成了一个封闭的环境包,共同返回出来 —-> 闭包
  4. 函数的作用域,取决于声明时而不取决于调用时

参考资料:
https://juejin.cn/post/6844903777808416776
https://www.cnblogs.com/rubylouvre/p/3345294.html
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures
https://segmentfault.com/a/1190000003818163

javascript - 作用域与变量提升

函数作用域

函数作用域还是比较简单,但凡写过 JS 的小伙伴也知道下面的示例代码为什么会输出这种结果,因为变量 count 是在函数作用域内定义的,那么 count 的作用范围则仅在 myFunction 内生效,所以外部是无法访问到 count 变量的。

1
2
3
4
5
6
7
function myFunction() {
var count = 1;
count++;
console.log(count); // 2
}
myFunction();
console.log(count); // a is not defined

块作用域

对于后端的开发者来说,特别是用 ruby 语言的,块作用域应该是用得比较多,因为在 ruby 中,可以往函数传递一个块来进行执行。但是 js 就 相对来说没这么复杂,甚至还很少显式的用到块作用域。

1
2
3
4
5
6
7
var right = true
if (right) {
var count = 0;
count ++;
console.log(count); // 1
}
console.log(count); // ?

对于 ? 处会打印什么?在我第一次看到这段代码的时候,我也会直觉性的认为他会打印 count is undefined,因为我认为 count 的包含在 if 的块作用域内的,然而事实是 ? 处的 count 也打印了 1,问题在于我忽略了变量提升这茬事了,稍后也会描述到,现在可以简单理解为在 if 块内用 var 声明的变量,最终也会等同于在 if 外层声明

let

基于上述的块作用域提到的示例代码片段,产生了与预期不一致的行为 – 预期是 count 仅在 if 块内可用,如何才能产生预期的情况?很简单,把 var 改为 let 即可。

let 是 es6 引入的新的关键字,提供了除 var 以外的另一种变量声明的方式,let 最有用的地方在于声明的变量仅在块内起作用

再次执行上述代码,发现与预期情况一致了:count 仅在 if 作用域内生效

1
2
3
4
5
6
7
var right = true
if (right) {
let count = 0;
count ++;
console.log(count); // 1
}
console.log(count); // Uncaught ReferenceError: count is not defined

除此以外 ,也可以 显示的定义块作用域,如下代码也能产生预期效果:

1
2
3
4
5
6
7
8
9
var right = true
if (right) {
{
let count = 0;
count ++;
console.log(count); // 1
}
console.log(count); // Uncaught ReferenceError: count is not defined
}

const

除了 let 的声明 ,const 也是 es6 引入的新的变量声明方式,最初学习的时候,我认为用 const 方式声明的变量后,随后就不能被改动了。但慢慢我认识到这是错误的。const 实际保证的,不是变量的值不能改变,而是变量指向的内存地址不能改动。因此,在使用复合对象类型的时候需要尤其注意,比如对象和数组
示例片段一:

1
2
3
const count = 0
count++ // count 不可变,报错
console.log(count)

示例片段二:

1
2
3
4
5
6
const arr = [0]
arr.push(1)
console.log(arr) // [0, 1]

const arr1 = [0]
arr1 = [2,3] // 报错

片段三:

1
2
3
4
5
6
const stu = {
name: 'yigger',
sex: 1
}
stu.age = 18
console.log(stu) // {name: "yigger", sex: 1, age: 18}

片段四:

1
2
3
4
5
6
7
8
9
10
const stu = {
name: 'yigger',
sex: 1
}
stu.age = 18

// 无法重新赋值,报错
stu = {
site: 'yigger.cn'
}

try/catch

另外,值得注意的是,try/catch 也会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

1
2
3
4
5
6
try {
null();
} catch (e) {
console.log(e) // TypeError: null is not a function
}
console.log(e) // Uncaught ReferenceError: e is not defined

变量提升

在最初写 JS 代码的时候,以下代码让我感到非常困惑,我还一度误以为 JS 是自带多线程执行代码的,否则怎么可以在变量/函数声明之前进行调用呢? 后来才慢慢了解到,原来 JS 有变量提升的机制。

1
2
3
4
5
6
7
sayHi() // hi, yigger
console.log(myName) // undefined

var myName = 'yigger'
function sayHi() {
console.log('hi, yigger')
}

首先,JavaScript是单线程语言,所以执行肯定是按顺序执行。但是并不是逐行的分析和执行,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。

在编译阶段阶段,代码真正执行前的几毫秒,会检测到所有的变量和函数声明,所有这些函数和变量声明都被添加到名为Lexical Environment的JavaScript数据结构内的内存中,所以这些变量和函数能在它们真正被声明之前使用。

  • var 变量提升
1
2
3
4
5
6
7
8
9
10
console.log(myName) // undefined
var myName = 'yigger'
console.log(myName) // yigger

// 由于变量提升,以上代码等同于

var myName;
console.log(myName) // undefined
myName = 'yigger'
console.log(myName) // yigger

代码这么写就清晰了许多,具体原因是当 JavaScript 在编译阶段会找到 var 关键字声明的变量会添加到词法环境中,并初始化一个值 undefined,在之后执行代码到赋值语句时,会把值赋值到这个变量

  • let/const 的声明会变量提升吗?
    答案是会的,事实上所有的声明(function, var, let, const, class)都会被“提升”,那你可能会问 ,既然会提升,下述代码理应会输出 undefined,而不是 error
1
2
console.log(myName) // Uncaught ReferenceError: Cannot access 'myName' before initialization
let myName = 'yigger'

这是因为在编译阶段,JavaScript引擎遇到变量 myName 并将它存到词法环境中,但因为使用 let 关键字声明的,JavaScript引擎并不会为它初始化值(而 var 会初始为 undefined),所以在编译阶段,此刻的词法环境像这样:

1
2
3
lexicalEnvironment = {
myName: <uninitialized>
}

如果我们要在变量声明之前使用变量,JavaScript引擎会从词法环境中获取变量的值,但是变量此时还是uninitialized状态,所以会返回一个错误ReferenceError。

完。

参考链接:

Rails 路由源码分析

Rails 路由

本文的路由分析是基于 rails 4.0.13

前言

在创建完 rails 应用以后,默认的会生成 config/routes.rb 文件,平时我们所用到的路由入口都可以定义此处,而使用方法也很简单,只需要在块内简单定义一下路由的路径和响应的 Controller 即可,如下所示,可以简单的定义一个 http GET /welcome 的请求,然后到 HomeController 找到名为 welcome 的 action 并执行熟悉的 MVC 过程(ps: 至于代码如何响应并发送,不过这个暂时不在本节讨论范围,本节主要是看看路由是怎么被定义的

1
2
3
Rails.application.routes.draw do
get '/welcome' => 'home#welcome'
end

路由是如何被定义的?

下面开始从第一行代码开始分析,Rails.application.routes.draw 做了什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# file: railties-4.0.13/lib/rails/engine.rb
# Rails.application.routes
# routes 是类方法,它初始化了 RouteSet 对象
def routes
@routes ||= ActionDispatch::Routing::RouteSet.new
@routes.append(&Proc.new) if block_given?
@routes
end

# file: actionpack-4.0.13/lib/action_dispatch/routing/route_set.rb
# Rails.application.routes.draw
# draw 方法接受一个代码块,就是 do .. end 包裹的一系列代码块区域
def draw(&block)
clear! unless @disable_clear_and_finalize
eval_block(block)
finalize! unless @disable_clear_and_finalize
nil
end

# eval_block(block) 代码继续往下走
def eval_block(block)
# 省略部分代码
mapper = Mapper.new(self)
# 这个方法是处理我们定义的内容,通过此方法可以把 block 传到 mapper
# eg:
# Gitlab::Application.routes.draw do
# get '/welcome' => 'home#welcome'
# end
#
# instance_exec 可以看下面的官方说明
mapper.instance_exec(&block)
end

# PS:
# instance_exec 是顶级类 Object 的方法,它接收一个块,块内可以使用示例对象的上下文
# 官方示例:
class KlassWithSecret
def initialize
@secret = 99
end
end
# 实例化对象
k = KlassWithSecret.new
# instance_exec 接收一个块,块内可以正常使用实例化对象 k 的 @secret 对象(可能有点拗口,理解一下)
# 原文: https://apidock.com/ruby/Object/instance_exec
k.instance_exec do
@secret+4
end

那么,路由的定义可以简单的理解为

1
2
3
4
5
6
7
8
9
10
Rails.application.routes.draw do
get '/welcome' => 'home#welcome'
end

=>

m = ActionDispatch::Routing::Mapper.new
m.instance_exec do
get '/welcome' => 'home#welcome'
end

我们平时使用的 getpostresources 等等方法都属于这个类 ActionDispatch::Routing::Mapper 的实例方法

下面继续看看 get 方法做了什么事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module HttpHelpers
def get(*args, &block)
map_method(:get, args, &block)
end
def post(*args, &block)
map_method(:post, args, &block)
end
def put(*args, &block)
map_method(:put, args, &block)
end
# delete, patch 方法也是类似的定义

private
def map_method(method, args, &block)
# 过滤,筛选并返回最后一个参数
# a = [{a: 1}, {b: 2}]
# a.extract_options! => {:b=>2}
options = args.extract_options!
options[:via] = method
match(*args, options, &block)
self
end
end

无论是 get、post、put 或是其它,最终的处理逻辑都是跑到 match 方法,而 match 方法的使用方式有很多种,可以看到官方的注释和方法的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# match 'path' => 'controller#action'
# match 'path', to: 'controller#action'
# match 'path', 'otherpath', on: :member, via: :get
def match(path, *rest)
# 此方法主要处理我们在 route.rb 定义的所有内容
# 把 *rest 转化为 options,最终传入下一个方法 decomposed_match
end

def decomposed_match(path, options)
...
# 终于到了主菜部分,前面把数据组装好以后,通过这个方法把添加路由,具体怎么添加呢?
add_route(path, options)
...
end

def add_route(action, options)
# 此处省略 options 和 action 的处理,具体可以参考源代码
...
mapping = Mapping.new(@set, @scope, URI.parser.escape(path), options)
app, conditions, requirements, defaults, as, anchor = mapping.to_route
@set.add_route(app, conditions, requirements, defaults, as, anchor)
end

add_route 之后又 add_route,在阅读过程中出现了贼多的 add_route,感觉就是一层层的套娃。

But,此处就是最最最核心的方法,涉及 route 的存放位置,其实核心代码主要就是两行,咱们可以一行行进行解读, to_route 到底做了什么,这里就不展开说明了,简单来说 to_route 只是把我们的参数进行了处理,使之更加面向对象

app, conditions, requirements, defaults, as, anchor = mapping.to_route

变量名称 作用 备注
app Constraints 类实例对象,内部映射着对应的 controller 类
conditions 上层传过来的 options
requirements 可配置此路由的正则,或者 format;主要用在之后的路由匹配过程,不符合正则或 format 的情况是不会进入到对应路由的响应
default 用处未知
as 一般用作路由的别名,eg: get ‘repositories/latest/created’ => ‘project_tags#latest’, as: :latest_created_projects
anchor 用处未知

由以上可知,to_route方法的作用是对用户在 router.rb 定义的 dsl 做一个更细化的处理,处理完成后向 mapper 抛出对应的参数,比如路由的名称路由的规则路由的响应以及其它可配置选项。在此方法的最后,把这些参数都传到 @set 实例变量进行存储起来,而 @set 实例变量在开头 Rails.application.routes 就已经初始化了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Rails.application.routes.draw 的 Rails.application.routes 就是此方法
def routes
@routes ||= ActionDispatch::Routing::RouteSet.new
@routes.append(&Proc.new) if block_given?
@routes
end

# actionpack-4.0.13/lib/action_dispatch/routing/route_set.rb
class ActionDispatch::Routing::RouteSet
def initialize(request_class = ActionDispatch::Request)
...
@set = Journey::Routes.new
...
end
end

至此,我们已经解决了第一个问题,路由是怎么被定义以及怎么被存储的:路由在定义之后,在启动应用的过程(rails s),会加载 router.rb 文件,然后初始化类 @set = Journey::Routes.new,之后将我们定义的诸如get '/welcome' => 'home#welcome'经过加工处理后,存储到 @set 的 routers 变量

在阅读的途中,我画了一下调用栈来帮助理解
调用栈

二级索引与联合索引

上周在公司内部分享了关于索引的一些相关概念,在会议最后,有两位同事想我提出了若干问题,这里就单独抽出来聊一聊
原问题:二级高效还是联合高效,还是根据场景来定?

谈到索引,主要分几大类,聚簇索引二级索引联合索引,先回顾一下这三种索引对应的概念

聚簇索引

聚簇索引就是以主键值作为索引而建立的一颗 b+ 树,子节点包含记录完整的数据,举例说明

1
2
3
4
5
6
7
8
9
10
11
12
- 创建表 issues
CREATE TABLE `issues`(
`id` INT UNSIGNED AUTO_INCREMENT,
`title` varchar(255),
`author_id` int,
PRIMARY KEY ( `id` )
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

insert into issues(`title`, `author_id`) values('test_01', 3)
insert into issues(`title`, `author_id`) values('test_02', 10)
insert into issues(`title`, `author_id`) values('test_03', 20)
insert into issues(`title`, `author_id`) values('test_04', 5)

画成 mysql 结构的聚簇索引 (由id建立起来的索引) b+ 树后,结构是如此的,底下的页10页22页8都称为树的子节点,他们内部存储的都是数据表记录,注意,它是**按主键顺序或者rowid(当没有显式定义主键或唯一索引就会默认用 rowid 作为主键,当然了这个是隐藏列,平时我们是看不到的) 的大小顺序组成的..**,而页 10086 也即是我们平时所说的索引页,一颗树中可能有许许多多的索引页,简单画了下大概结构如下:

输入图片说明

所以,总结如下:聚簇索引就是由 id 作为索引建立起来的 b+ 树,它是默认就存在的,换言之,不需要你手动创建,innodb引擎会自动帮你创建,如果觉得这棵树有点别扭,可以参考下这句话 数据即索引,索引即数据

二级索引

在聚簇索引之上创建的索引称之为辅助索引,辅助索引访问数据总是需要二次查找。辅助索引叶子节点存储的不再是行所有内容,而是主键值 + 你建立的索引。通过辅助索引首先找到的是主键值,再通过主键值找到数据行的数据叶,再通过数据叶中的Page Directory找到数据行。

对比上面的图,我们可以试着创建一个二级索引

1
alter table issues add index index_by_author_id(author_id);

然后b+树的索引结构就会转变为,如图所示
不同点在于子节点不再存储完成的列记录,对比可见 title 字段已经没有记录在这些子节点出现了
输入图片说明

所以就会有如下区别

1
2
3
4
5
6
7
- 1. 此 sql 会首先在二级索引(index_by_author_id)中先找到对应记录,找到后发现子节点竟然没有 title 字段
- 2. 找到对应记录后,再次到聚簇索引进行查找 title 记录
select title from issues where author_id = 2;


- 此 sql 会首先在二级索引(index_by_author_id)中先找到对应记录,找到后发现子节点有 id 和 title 字段,直接返回
select id, author_id from issues where author_id = 2;

联合索引

联合索引最为常用,比如我要为author_id 和 title建立联合索引,字段有先后次序之分哦,联合索引(author_id, title) 和 (title, author_id) 是不同滴,(语法省略..),比如建立 (author_id, title),此索引包含两层含义:

  1. 先把各个记录和页按照 author_id 列进行排序。
  2. 在记录的 author_id 列相同的情况下,采用 title 列的大小进行排序

输入图片说明

在查找过程中,其实主要区别也是和二级索引的类似,如果 select 的字段没有命中索引,那么也是要到聚簇索引进行二次查找的,所以回到最开始的问题,哪个高效?其实硬要选出一个的话,那么当然是二级索引较为高效,因为它的建立索引的字段列少,对比联合索引可以减少循环判断,但是最主要的还是取决于是否需要回表,即 select 语句是否有查询非索引的列…

后端分页技术探讨

目前对于网页或者 APP 大致分为两种分页模式,一种是传统的通过页码进行分页,另一种通过流式分页,所谓流式分页即滚动加载,当页面滚动到屏幕底部时,自动获取下一页,而不能通过点击跳转的方式获取某一页的内容。

传统分页(百度搜索的底部)
百度的分页

流式分页(京东 app 的首页下滑)
京东app的分页

你可能会问,为什么不都采用传统的分页呢?

一方面可能是因业务经理的要求,某块业务不希望用户可以进行跳转搜索,增加产品的曝光率。比如我付钱投广告了,希望排在靠前一点,那么如果用传统分页的话,浏览器用户可能会通过跳转页码而没能访问到对应的商品。

另一方面原因是因为 PC 端的可视范围比较大,可以展示更多的页码,而手机屏幕才这么一点屏幕尺寸,而且由于触屏手机,点击这么小的分页框还是相对困难的,从整个用户体验考虑来说也是并不合适的。

技术实现

前端

一般情况下,在进行前后端交互的时候,前后端都会约定好参数的规范,比如:https://yigger.cn/list 接口,后端通常希望前端传入两个参数来达到分页的效果。https://yigger.cn/list?page=1&per=10 代表第 1 页,取10条数据

参数 含义
page 代表第几页
per 一次取多少条数据

后端

在一般的网页中,我们采用 mysql 进行分页,通常 sql 是这样的

1
2
3
4
SELECT * from users limit 10 offset 0    // 第一页,理解为从[0-9]取出
SELECT * from users limit 10 offset 10 // 第二页,理解为从[10-20]取出
SELECT * from users limit 10 offset 20 // 第三页,理解为从[21-30]取出
SELECT * from users limit 10 offset 30 // 第四页,理解为从[31-40]取出

但 SQL 这里的 offset 和 limit 参数跟前端传来的 page 和 per 参数并没关系啊?别急,我们可以通过等式推到出来
offset: (page - 1) * per ps: page > 1
limit: per

存在的技术缺陷

1. 数据缺失

为了简单说明问题,我们用数据来模拟分页的情况,假设存在以下数组,并要对此数组进行分页
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
第一页,offset=0,limit=10,从数组索引为 0 的位置开始获取10个元素,得到的结果为

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

假设此时把 9 从数组中移出,那么原数据就会变为

[1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

此时,再获取第二页,offset=10,limit=10,从数组索引为 10 的位置开始获取 10 个元素:由于删除了一个元素,所以此时索引为 10 的元素是 12,我们从元素 12 的位置开始获取 10 个元素,得到的结果是

[12, 13, 14, 15, 16, 17, 18, 19, 20]

此时对比一下,我们就能发现元素 11 本应该出现在第二页,但是由于删除了原数组,所以在第二页也没有获取到

1
2
3
数组: [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
第一页:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
第二页:[12, 13, 14, 15, 16, 17, 18, 19, 20]

2. 重复数据

我们还是利用之前的数组做实验

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

第一页,offset=0,limit=10,从数组索引为 0 的位置开始获取10个元素,得到的结果为

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

假设此时在元素 4 后面插入一个新的元素 999,此时原数组就会变成

[1, 2, 3, 4, 999, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

此时,再获取第二页,offset=10,limit=10,从数组索引为 10 的位置开始获取 10 个元素,结果为

[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

对比第一页和第二页获取的数据,我们发现元素 10 重复出现在第一页和第二页

1
2
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

解决方案

出现以上原因是因为我们每次分页时,数组都是在动态变化的,这种情况在大型网站很常见,比如淘宝,你在浏览第一页数据的时候,此时或者已经插入或删除了几十条数据,那么当你点击第二页的时候,按照传统排序来说第二页的数据是不那么准确的。当然,淘宝肯定通过复杂的技术解决了这个问题

那么对于大多数情况下,我们可以考虑以下方式进行解决:

a. 通过记录时间戳进行解决

首次获取数据时,后端除了返回数据内容以外,还需返回当前系统时间戳,前端下次分页查询时,把此时间戳传回给后端,后端就可以通过时间去筛选在这期间新增的数据内容,举例说明

1
2
3
4
5
create table users {
id int
name varchar(256)
created_at timestamp
}
id name created_at
5 A 2020-03-07 00:05:00
4 B 2020-03-07 00:04:00
3 D 2020-03-07 00:03:00
2 E 2020-03-07 00:02:00

假设一页获取 2 条数据,当前时间是 00:05:00,此时 SQL

1
select * from users limit 0,2 order by created_at desc

返回给前端的数据:json: { data: users(name=A, name=B), timestamp: '2020-03-07 00:05:00' }

此时新增了一条数据,排序后数据集为

id name created_at
6 C 2020-03-07 00:09:00
5 A 2020-03-07 00:05:00
4 B 2020-03-07 00:04:00
3 D 2020-03-07 00:03:00
2 E 2020-03-07 00:02:00
1
select * from users where created_at < '2020-03-07 00:05:00' limit 2,2 order by created_at desc

此时返回的数据集:json: { data: users(name=D, name=E) }

到此,已经可以解决由于新增数据造成的重复读的问题了。但是,漏读的情况还是存在的,也即是说在获取分页数据过程中,前面的数据被删除了。

b. 通过游标分页解决

很简单,每次都记录当前分页最后的 id,比如获取第一页的时候,最后的 id = 10,那么在获取第二页时,通过以下查询条件

1
2
SELECT * from users where id > 10 order by id asc // 第二页
SELECT * from users where id > 20 order by id asc // 第三页

这样查询得到的结果解决了以上两种问题,数据缺失和数据重复的问题,理论上已经是很完美的一种方案了,但实际上存在一个明显的弊端,他十分依赖 id 排序,可以看到上述两条 SQL 都有order by id asc

为什么呢?我们可以通过反证法,假设数组不是按 id 进行排序的,考虑如下数组
[1, 3, 5, 17, 29, 32, 4, 46, 8, 98, 12, 14, 13, 11, 15 …]

第一次获取的结果:[1, 3, 5, 17, 29, 32, 4, 46, 8, 98],最后一个 id = 98
下一次获取的 SQL:SELECT * from users where id > 98,此时返回为空数组

c. 一次性下发所有 id,由前端进行分页

这种方式比较粗暴,在一开始的时候把所有 id 都下发给客户端,然后客户端每次都通过此 id 数组进行分页查询。

这是一个博主提出的方案,但是我猜实际上没人会用这种方式进行分页,因为在中大型网站商品表基本上都有几十万上百万条数据,那浏览器岂不是要获取几百万的数组进行分页?

但是我们可以采取折中的方式,一般情况下用户感兴趣的都只会翻几页,那么我们可以假设用户会翻 20 页的数据,那么我们可以一次性获取 20 页数据的 id ,假设每页 10 条,那么数据长度共计 20 * 10 = 200 个 id。

所以当在20页范围之内,我们可以采取前端传送 id 数组给后端获取数据,此时后端的 sql select * from users where id IN (1..20),当超过 20 页,再通过后端分页获取数据 select * from users offset 201 limit 10

d. 借助 redis sortedset 结构

Mysql 不能很好地解决从一个很长的序列中取出任意一段数据的问题,而造成这一问题的根源在于这些数据是存放在磁盘上的,磁盘不适合做此类的随机读的操作。所以想,如果能有一个程序,管理一些很大很大的放在内存中排序数组就好了,因为对内存中的数组做下标访问,是非常快速的。做了一下调查正好发现,redis提供了此类的功能。

ZADD key score member [[score member] [score member] …]

将一个或多个 member 元素及其 score 值加入到有序集 key 当中。如果某个 member 已经是有序集的成员,那么更新这个 member 的 score 值,并通过重新插入这个 member 元素,来保证该 member 在正确的位置上

对于复杂的排序,我们可以通过把数据都 load 到 redis 的 sortedset 结构里面,并通过 排序值 和 score 的映射关系进行排序,那么再每次进行分页的时候,我们都可以直接在 redis 里面进行数据的获取与分页,这在一定程度上其实也解决了下面所说的 mysql 分页存在的性能问题

分页是否存在性能问题?

通过 mysql 进行分页确实会存在性能问题

1
select * from users order by id desc limit 1000000,10

普通的一句 SQL 其实蕴含着大问题

  1. 使用了*返回字段,全字段返回的问题就是要扫描全表
  2. 进行了ORDERBY排序,我测试的这个表只有几百万数据
  3. 最后分页是取的 100 万开始的 10 条,等于是要扫描100万条数据才开始

上述SQL执行的速度大概在 2-3s 左右,为什么会如此之慢?

归根结底都是因为当用户指定翻到第n页时候,并没有直接方法寻址到该位置,而是需要从第一楼逐个count,scan到countpage时候,获取数据才真正开始,所以导致效率不高。对应的算法复杂度是O(n),n指 offset,也就是pagecount

实际上可以通过索引直接定位到相应的位置,我们可以优化一下此 SQL,变为

1
select * from users where id > 999999 order by id desc limit 1000000, 10

此时再执行 SQL,返回的时间只有几毫秒

总结

实际上,笔者在查询此分页的解决方案的时候,看了很多文章,大多数用户都表达出自己的见解,但是总结多种方案后发现其实也就以上那几种方案,无论哪种方法也好,都不能彻底解决分页存在的种种问题。建议在实际业务场景中,和产品经理和技术经理进行权衡,是否需要花费 80% 的时间来解决 20% 的系统性能?是否允许产品分页存在一些并不是很完美的存在呢?具体选用哪种技术实现方案还需要根据不同的业务场景进行看待。

参考链接

https://aotu.io/notes/2017/06/27/infinite-scrolling/index.html
https://cloud.tencent.com/developer/article/1019683
https://juejin.im/post/5db658faf265da4d500f8386
https://www.jianshu.com/p/13941129c826
https://timyang.net/data/key-list-pagination/
https://www.v2ex.com/t/328806

HTTP 1.0/1.1/2.0

HTTP 的前世今生

http 早在 1990 年问世,它是稍早于 1.0 的版本,这个版本称作 http 0.9 。直到1996 年 5 月, HTTP 正式作为标准进行公布,这就是早期的 HTTP 1.0,随后的一年,1997 年 1月,又接着公布了 HTTP/1.1。在接下来的日子里几乎没有更新,而我们现在听到的 HTTP 2.0 是怎么回事呢?HTTP 2.0 在2013年8月进行首次合作共事性测试。在开放互联网上HTTP 2.0将只用于https:// 网址,而 http:// 网址将继续使用HTTP/1,目的是在开放互联网上增加使用加密技术,以提供强有力的保护去遏制主动攻击。

http 与 tcp 的关系

每当我们谈及 http ,我们总是会联系到 tcp/ip。实际上,我们通常使用的网络是在 TCP/IP 协议族的基础上运作的,而 HTTP 则是这 TCP/IP 协议族中的其中一员,其中我们耳熟能详的 ICMP、DNS、FTP、TCP、UDP 等等也是在TCP/IP 协议族里面。

TCP/IP 协议族一般分为 4 层,应用层,传输层,网络层,数据链路层。在利用 TCP/IP 协议族进行通信时,会通过分层顺序与对方进行通信,发送端从上往下走,接收端从下往上走。如图所示

通信过程

HTTP 方法

  • GET:获取资源
  • POST:传输实体的主体
  • PUT:传输文件
  • DELETE:删除文件
  • HEAD:获得报文首部
  • OPTIONS:询问支持的方法
  • TRACE:追踪路径
    主要从事 Web 方面的开发,RESTful 肯定绕不开,而使用RESTFul,GET、POST、PUT、DELETE 一定是十分常用的,就不多说了,剩下的其实不是很常用。

HEAD方法,在服务器响应时不会返回消息体。一个HEAD请求的响应中,HTTP头中包含的元信息应该和一个GET请求的响应消息相同。这种方法可以用来获取请求中隐含的元信息,而不用传输实体本身。也经常用来测试超链接的有效性、可用性和最近的修改。

值得一提的是,在前端跨域的解决过程中,当采用 CORS 方法来解决跨域问题的话,非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight),”预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。

HTTP 响应状态码

1XX

  • 100 Continue
    客户端继续其请求

  • 101 Switching Protocols
    切换协议。服务器根据客户端的请求切换协议,比如切换为 websocket 的连接

2XX 成功

  • 200 OK
    请求成功

  • 204 No Content
    无内容返回

  • 206 Partial Content
    部分内容。服务器成功处理了部分 GET 请求

3XX 重定向

  • 301 Move Permannently
    永久性重定向。该状态码表示请求的资源已被分配了新的URI,以后应使用资源现在所指的URI。
    (比如:客户端接收到请求后,把书签的引用进行变更,换为新的地址)

  • 302 Found
    表示临时重定向 Moved Temporarily。由于这样的重定向是临时的,客户端应继续向原有地址发送以后的请求,只有在 Cache-Control 或 Expires 中进行了指定的情况下,这个响应才是可缓存的。

  • 303 See Other
    作用和 302 基本一致,表示新的资源在其它URI,需要用 GET 方法请求它。 302 也能实现,但是当你希望客户端明确的使用 GET 请求进行访问另一个 URI 时应当使用 302 比较符合规范。

  • 304 Not Modify
    当客户端的请求头含有 IF-Modified-Since、If-None-Match、If-Range、If-Unmodified-Since 任一首部时,服务端可以返回 304,表示客户端当前请求的资源没有变化,可以使用客户端本地的缓存,所以此时服务端不会返回任何主体数据。

  • 307 Temporary Redirect
    临时重定向,该状态码和 302 有着同样的含义,只是因为在日常使用中,302 标准是禁止 Post 请求转换为 Get 请求,但是实际使用时大家都不遵守这个标准。所以,就有了 307,它硬性的规定 POST请求不能变为 Get 请求,但是每种浏览器都有可能出现不同的情况

简单的总结

  • 301:永久重定向,客户端以后不应该再次请求此地址
  • 302:临时重定向,客户端可以(也应该)再次请求此地址
    P.S. 由于 302 语义不明确,所以不推荐使用 302 重定向,应该使用 303 或者 307 代替
  • 303:临时重定向,并使用 GET 方法请求新的 URL
  • 307:临时重定向,并使用原方法请求新的 URL

4XX 客户端错误

  • 400 Bad Request
    请求格式错误,比如说后端校验请求的时候发现请求参数不对的时候可以返回此状态码

  • 401 Unauthorized
    未授权。常见的如未登录的用户访问资源

  • 403 Forbidden
    无权限访问此资源

  • 404 Not Found
    资源找不到

  • 409 Conflict
    表示请求的资源与资源的当前状态发生冲突

  • 410 Gone
    表示服务器上的某个资源被永久性的删除。

5XX 服务端错误

  • 500
  • 503
    服务端错误/服务端超载

HTTP 报文

http 分为请求报文和响应报文,它们都差不多,主要区别于各自的报文首部
请求报文的组成部分:报文首部,报文主体,首部和主体之间使用回车符和换行符进行分隔。
请求报文

例子:

  • 请求报文
    报文首部分为:请求行、请求首部字段

    1
    2
    3
    4
    5
    6
    // 这是请求行
    GET /index.html HTTP/1.1
    // 以下是请求首部字段
    Host: www.baidu.com
    Connection: close
    User-agent: Mozilla/5.0

    GET 是没有请求主体的,POST 才有,平时我们提交的表单数据就是通过请求主体进行传输的

  • 响应报文
    报文首部分为:状态行、响应首部字段

    1
    2
    3
    4
    HTTP/1.1 200 OK
    Content-Length: 662
    Content-Type: text/plain; charset=UTF-8
    Date: Wed, 19 Feb 2020 14:49:41 GMT

    响应主体就是服务端传输回来的文本数据

HTTP1.0 vs 1.1 vs 2.0

1.0 1.1 2.0
长连接 需要使用keep-alive 参数来告知服务端建立一个长连接 默认支持 默认支持
HOST域 X
多路复用 X -
数据压缩 X X 使用HAPCK算法对header数据进行压缩,使数据体积变小,传输更快
服务器推送 X X

长连接

http1.1默认保持长连接,数据传输完成保持tcp连接不断开,继续用这个通道传输数据

管道化

基于长连接的基础,我们先看没有管道化请求响应:

tcp没有断开,用的同一个通道

1
请求1 > 响应1 --> 请求2 > 响应2 --> 请求3 > 响应3

管道化的请求响应:

1
请求1 --> 请求2 --> 请求3 > 响应1 --> 响应2 --> 响应3

即使服务器先准备好响应2,也是按照请求顺序先返回响应1

虽然管道化,可以一次发送多个请求,但是响应仍是顺序返回,仍然无法解决队头阻塞的问题

题外话:为什么管线化必须按照顺序进行返回呢?
由于 HTTP/1.1 是个文本协议,同时返回的内容也并不能区分对应于哪个发送的请求,所以顺序必须维持一致。比如你向服务器发送了两个请求 GET /query?q=A 和 GET /query?q=B,服务器返回了两个结果,浏览器是没有办法根据响应结果来判断响应对应于哪一个请求的。

缓存处理

当浏览器请求资源时,先看是否有缓存的资源,如果有缓存,直接取,不会再发请求,如果没有缓存,则发送请求

通过设置字段cache-control来控制

http2.0 特性

二进制分帧

HTTP/1.1的头信息是文本(ASCII编码),数据体可以是文本,也可以是二进制;HTTP/2 头信息和数据体都是二进制,统称为“帧”:头信息帧和数据帧

多路复用(双工通信)

http/2之前,每次请求都需要客户端主动发送请求信息,才能得到响应信息,虽然 http1.1 有管道化的特性,可以同时发送多个请求给服务端,但是由于阻塞性,所以即使是多个请求同时发送,但响应也是按顺序进行返回。

http/2 引入了多路复用,此时可以解决这种囧局,在一次连接中,客户端和服务端都可同时进行发送和接收操作,而不需要按照顺序一一对应,这样避免了队头阻塞的问题。

题外话:为什么 HTTP1.1 不能实现多路复用?
HTTP/1.1 不是二进制传输,而是通过文本进行传输。由于没有流的概念,在使用多路复用传递数据时,接收端在接收到响应后,并不能区分多个响应分别对应的请求,所以无法将多个响应的结果重新进行组装,也就实现不了多路复用。

header压缩

HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。HTTP/2 对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息压缩后再发送(SPDY 使用的是通用的DEFLATE 算法,而 HTTP/2 则使用了专门为首部压缩而设计的 HPACK 算法)。另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。

服务器推送

HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。常见场景是客户端请求一个网页,这个网页里面包含很多静态资源。正常情况下,客户端必须收到网页后,解析HTML源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。

HTTP/2 缺点

HTTP 是基于 TCP 实现的,但是 TCP 有个缺陷,丢包重传。HTTP/2出现丢包时,整个 TCP 都要开始等待重传,那么就会阻塞该TCP连接中的所有请求。

参考地址

https://blog.csdn.net/ChenglinBen/article/details/90812365
https://zhuanlan.zhihu.com/p/61423830
https://mp.weixin.qq.com/s/sakIv-NidqkO1tviBHxtWQ

protocol buffer

protocol buffers 简介

protocol buffers 是由 google 开发的一款数据交换格式,可用于进行通信协议,数据存储。说人话就是类似于 XML、JSON 的一种用于序列化数据的东西,那么为什么不直接用 XML、JSON,而要重新开发 protocol buffers 呢?那还用说,当然是因为它又小又快。

简单概括:占容量小、速度快、平台无关、语言无关

protobuf 的简单使用

protobuf 的使用方法很简单,它比较类似于定义一个结构体,但是只有属性,没有方法。

另外,protobuf 目前有两个版本,一个是 proto2,另一个是 proto3,虽然 proto3 看上去比 proto2 新,但是在一些处理上其实还不如 proto2,比如说默认值和未定义的字段的处理就不如 proto2。但是 proto3 确实也修复了不少的问题和新增了 feature,所以一般情况下都会选用 proto3。

附Proto3 区别于 Proto2 的使用情况

  • 在第一行非空非注释行,必须写:syntax = “proto3”;
  • 字段规则移除 「required」,并把 「optional」改为 「singular」
  • 「repeated」字段默认使用 paced 编码
  • 移除 default 选项
  • 枚举类型的第一个字段必须要为 0
  • 移除对扩展的支持,新增 Any 类型, Any 类型是用来替代 proto2 中的扩展的
  • 增加了 JSON 映射特性

值得注意的是,如果要使用 proto3,那么在定义的时候第一行必须填写 syntax = "proto3"否则,将按照 proto2 的语法进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
// filename: login.proto
syntax = "proto3";
package pb;
message LoginReq {
string username = 1;
string password = 2;
}

message LoginResp {
string code = 1;
string msg = 2;
}

对于 JSON 数据,我们可以通过语言层面去直接解析,但是 protobuf 不行,在使用之前,我们需要把文件使用工具编译一下

编译器可以在 https://github.com/protocolbuffers/protobuf 官方的 release 找到并进行安装,安装之后,在当前文件目录下执行 protoc --go_out=. login.proto 后,会生成 login.pb.go 文件,该文件生成以后,我们就可以通过引用来直接使用了。

1
2
3
4
5
6
7
8
9
10
func main(){
req := &pb.LoginReq{
username: "test",
password: "123"
}

resp = &pb.LoginResp{
...
}
}

使用场景

作为 RPC 的数据交换相对来说用得比较多,grpc 默认也是与 protocol buffer 搭配一起使用

编码原理

ProtocolBuffers 使用 Varint 进行编码。

Varint 是一种紧凑的表示数字的方法。它用一个或多个字节来表示一个数字,值越小的数字使用越少的字节数。这能减少用来表示数字的字节数。

Varint 中的每个字节(最后一个字节除外)都设置了最高有效位(msb),这一位表示还会有更多字节出现。每个字节的低 7 位用于以 7 位组的形式存储数字的二进制补码表示,最低有效组首位。

优缺点

优点

首先我们来了解一下 XML 的封解包过程。XML 需要从文件中读取出字符串,再转换为 XML 文档对象结构模型。之后,再从 XML 文档对象结构模型中读取指定节点的字符串,最后再将这个字符串转换成指定类型的变量。这个过程非常复杂,其中将 XML 文件转换为文档对象结构模型的过程通常需要完成词法文法分析等大量消耗 CPU 的复杂计算。

反观 Protobuf,它只需要简单地将一个二进制序列,按照指定的格式读取到 C++ 对应的结构类型中就可以了。

所以结论当然是:容量小,速度快,跨平台

缺点

通用性来说的话,如果面向开放的 api 的话,还是 JSON 比较通用,为什么呢?一方面是因为大家都熟悉这套玩法了,另一方面就是省事,不用每次都编译 proto 文件

参考链接

https://developers.google.com/protocol-buffers/docs/proto3
https://halfrost.com/protobuf_encode/#proto3message
https://www.ibm.com/developerworks/cn/linux/l-cn-gpb/index.html
https://www.cnblogs.com/makor/p/protobuf-and-grpc.html

golang迭代变量

在学习 golang 的过程中,遇到一个比较有意思的问题,也是书中指出的需要重点注意的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
arr := []int{2,3,4,5}
var ap []func()int
// 循环把求值的函数放入到函数数组
for _, v := range arr {
ap = append(ap, func() int {
return v * v
})
}

// 此处遍历并依次调用函数
for _, v := range ap {
fmt.Print(v())
}

预期结果我想应该是 4,9,16,25 吧?结果实际输出出乎意料,竟然输出了 25 25 25 25

原因是 v 是在 range 的块作用域里面,在循环里创建的所有函数变量共享相同的变量 - 一个可访问的存储位置,而不是固定的值

可以简单的输出一下 v 的地址来直观的看待此问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
f := test()
fmt.Println(f())
fmt.Println(f())

arr := []int{2,3,4,5}
var ap []func()int
for _, v := range arr {
fmt.Println(&v) // 输出 v 的地址
ap = append(ap, func() int {
return v * v
})
}

for _, v := range ap {
fmt.Print(v())
}
}
// 输出
0xc00005c0a8
0xc00005c0a8
0xc00005c0a8
0xc00005c0a8

可以看到,每次迭代过程, v 的地址都是一样的,所以,匿名函数里面保存的 v 也是一样的,循环结束后,v 最终的值变成了 25 ,所以,在遍历 ap 的时候,输出的结果全都是 25(因为引用的都是同一个变量)

初识 channel

在了解了 goroutine 之后,我们知道,协程都是独立运行的,如果我们完全不关心协程的执行结果,那大可不需要 channel。然而在实际场景中,你肯定需要从协程中获得返回值,或者把某个协程的结果输入到另一个协程中去,这就涉及到协程之间是怎么进行数据通信

其实 channel 的概念跟 C 的 pipe(管道) 很类似,我们可以先了解一下 C 语言是怎么进行通信的

C pipe

C pipe 是 Unix 下的一种 IPC(进程间通信) 方式,它是半双工的,也就是说,数据只能从一个地方流动,如果需要双方建立通信,则需要两个管道。

C pipe 的函数定义:

1
2
3
4
头文件:unistd.h
int pipe(filedes[2]);
filedes[2]:输出参数,用于接收pipe返回的两个文件描述符;filedes[0]读管道、filedes[1]写管道
返回值:成功返回0,失败返回-1,并设置errno

OK,大致了解了管道是怎么回事,那么尝试下用 C 基于 pipe 实现进程间通信呗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
int main(int argc, char *argv[]){
if(argc < 2){
fprintf(stderr, "usage: %s parent_sendmsg child_sendmsg\n", argv[0]);
exit(EXIT_FAILURE);
}

// 定义 pipe 数组,作为参数传入到 pipe 函数
// 以上就已经创建了一个管道了,分别有读端和写端
int pipes[2];
if(pipe(pipes) < 0){
perror("pipe");
exit(EXIT_FAILURE);
}

pid_t pid = fork();
if (pid < 0) {
exit(EXIT_FAILURE);
} else if(pid > 0) {
// 先掐掉管道读取的那一端
close(pipes[0]);

char buf[BUFSIZ + 1];
// 获取终端输入的值
strcpy(buf, argv[1]);
// 把这个值塞入到管道里面去
write(pipes[1], buf, strlen(buf));
} else if (pid == 0) {
char buf[BUFSIZ + 1];
// 掐掉管道写入的那一端
close(pipes[1]);
// 子进程从管道里面取数据
int nbuf = read(pipes[0], buf, BUFSIZ);
buf[nbuf] = 0;
printf("子进程 (%d) 成功接收到父进程发来的贺电: %s\n", getpid(), buf);
}

return 0;
}

编译运行后

1
2
3
$ gcc pipe.c
$ ./a.out haha
$ 子进程 (68567) 成功接收到父进程发来的贺电: haha

大致的流程如下:
image

其实也可以不关闭管道,让两端都可以通信,但是一般不推荐使用匿名管道 pipe 来实现单一进程下既可读又可写,而是使用命名管道 mkfifo。当然了,这里只是做简单的了解,毕竟目前我还是想去了解 go 的 channel

通道阻塞

pipe 是进程内通信的一种方式,而 channel 也是 go 协程间进行通信的一种数据结构。
默认情况下,通过 c = make(chan int) 创建的 channel 都是无缓冲的,如果把 channel 类比成队列,这种方式下创建的队列的长度为 1,又因为 channel 的写入和获取都是同步的,
所以每个定义的 channel 都需要读端和写端

如以下代码是不被允许的,因为 dataChannel 一直在等待 channel 写入数据(因为只有”别人”往里面写入数据了,它才能真正执行到)

1
2
3
4
func main() {
dataChannel := make(chan int)
<- dataChannel
}

那是不是改成这样就可以了呢?

1
2
3
4
5
func main() {
dataChannel := make(chan int)
dataChannel <- 1
<- dataChannel
}

同样的,这也会报 runtime 的错误,虽然 dataChannel 已经写入了,但是代码一直都还是在 dataChannel <- 1 这个地方阻塞着,等待别人从通道中 “读走” 数据

解法一

解决方案可以把 dataChannel <- 1 扔到协程,或者把 <- dataChannel 扔到协程,都行

1
2
3
4
5
6
7
8
9
10
11
12
13
dataChannel := make(chan int)
go func(){
fmt.Println(<- dataChannel)
}()
dataChannel <- 4

或者

dataChannel := make(chan int)
go func(){
dataChannel <- 4
}()
fmt.Println(<- dataChannel)

因为把阻塞的操作都扔到 go 协程,主协程虽然是阻塞的,但是程序知道某一刻一定会存在有人把我这个值给“读走/写入”,所以可以这么改

但是,如果互换位置的就不行了,比如这样

1
2
3
4
5
dataChannel := make(chan int)
dataChannel <- 4
go func(){
fmt.Println(<- dataChannel)
}()

因为程序在 dataChannel <- 4 这一步就已经阻塞了,换句话说,也就是永远都不会执行下面的 go func

解法二

1
2
3
4
5
dataChannel := make(chan int, 2)
dataChannel <- 4
go func(){
fmt.Println(<- dataChannel)
}()

这段代码跟上一段的唯一区别就在 dataChannel 初始化的时候,加上了 2 的参数,代表该队列的长度是 2 个,也就是说可以容纳两个元素,超出,就会继续堵塞,这种就是缓冲通道
在有空余位置的情况下,不会阻塞当前主协程

使用通道实现超时

go 超时的实现也是相当有趣,因为 go 不像其它动态语言,只需要简简单单一句 timeout(1000) 就可以实现超时,go 的超时也是通过 channel 的 select 来实现的

第一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
timeout := make(chan bool)
exChan := make(chan int)
go func() {
time.Sleep(second)
timeout <- true
}()
go func(){
time.Sleep(2 * second)
exChan <- 23
}()
select {
case <- exChan:
fmt.Println("输出 exChan")
case <- timeout:
fmt.Println("超时了")
}

第二种:

1
2
3
4
5
6
7
8
9
10
11
exChan := make(chan int)
go func(){
time.Sleep(2 * second)
exChan <- 23
}()
select {
case <- exChan:
fmt.Println("输出 exChan")
case <- time.After(second):
fmt.Println("超时了")
}

死锁

可参考这篇文章

并发和并行的个人理解

并发和并行这两个概念基本上在学习多线程编程的时候总会碰到,虽然只有一字之差,但其实是两个不一样的概念,从英文单词的角度来看就能发现他的不一样之处,并发的英文单词是 Concurrency 而并行的英文单词是 Parallelism

我在理解这两种概念之前,看了很多篇文章,确实很多文章写得很优秀,比如知乎上的一些非常形象的比喻,足以让你一看就明白。但是,当我看完这些概念之后出去撒泡尿回来准备总结一下的时候,我脑海中只有什么“和尚挑水”、“美女”等故事关键词,要我重新组织语言我确实不知道该说些什么

直到我看了一篇写得非常好的文章,以下内容,是我自己结合文章的小小总结,如果想查看原文,请文章底部链接:

在开始之前,先深刻理解这句话

并发指的是程序的结构,并行指的是运行时的状态

并行(Parallelism)

顾名思义,并行是指程序同时执行,判断程序是否并行,只要看同一时刻存在两个执行流即可,真的不需要过度去解读这个词语。并行 => 同时执行

并发(Concurrency)

并发指的是程序的“结构”,当我们说这个程序是并发的,实际上,这句话应当表述成“这个程序采用了支持并发的设计”。好,既然并发指的是人为设计的结构,那么怎样的程序结构才叫做支持并发的设计?

正确的并发设计的标准是:使多个操作可以在重叠的时间段内进行

举个例子:程序需要执行两个任务,获取一段数据,把这段数据先写入文件(注:这个数据非常大,可能需要耗时 10s),然后再写入数据库(5s)

先来看一段不支持并发的设计:

1
2
3
write_to_file(data)
write_to_db(data)
// 这段代码一共耗时 10s + 5s = 15s

接下来再看支持并发的设计(使用go协程):

1
2
3
go write_to_file(data)
go write_to_db(data)
// 这段代码一共耗时 10s

为什么第二段代码耗时 10s?因为他们是并发执行的,也就是说在 write_to_file写入的 10s 这个时间段内,write_to_db操作也在进行,所以符合上面所说的使多个操作可以在重叠的时间段内进行

所以总结如下:并发并不要求必须并行,比如单核cpu上的多任务系统,并发的要求是任务能切分成独立执行的片段。而并行关注的是同时执行,必须是多(核)cpu,要能并行的程序必须是支持并发的。

参考文章:

还在疑惑并发和并行?