2023年10月

1 它是什么(协程 和 Kotlin协程)

1.1 协程是什么

维基百科:协程,英文Coroutine [kəru’tin] (可入厅),是计算机程序的一类组件,推广了协作式多任务的子程序,允许执行被挂起与被恢复。

作为Google钦定的Android开发首选语言Kotlin,协程并不是 Kotlin 提出来的新概念,目前有协程概念的编程语言有Lua语言、Python语言、Go语言、C语言等,它只是一种编程思想,不局限于特定的语言。

而每一种编程语言中的协程的概念及实现又不完全一样,本次分享主要讲Kotlin协程。

1.2 Kotlin协程是什么

Kotlin官网:协程是轻量级线程

可简单理解:一个线程框架,是全新的处理并发的方式,也是Android上方便简化异步执行代码的方式

类似于 Java:线程池 Android:Handler和AsyncTask,RxJava的Schedulers

注:Kotlin不仅仅是面向JVM平台的,还有JS/Native,如果用kotlin来写前端,那Koltin的协程就是JS意义上的协程。如果仅仅JVM 平台,那确实应该是线程框架。

1.3 进程、线程、协程比较

可通过以下两张图理解三者的不同和关系

2 为什么选择它(协程解决什么问题)

异步场景举例:

  1. 第一步:接口获取当前用户token及用户信息
  2. 第二步:将用户的昵称展示界面上
  3. 第三步:然后再通过这个token获取当前用户的消息未读数
  4. 第四步:并展示在界面上

2.1 现有方案实现

apiService.getUserInfo().enqueue(object :Callback<User>{
    override fun onResponse(call: Call<User>, response: Response<User>) {
        val user = response.body()
        tvNickName.text = user?.nickName
        apiService.getUnReadMsgCount(user?.token).enqueue(object :Callback<Int>{
            override fun onResponse(call: Call<Int>, response: Response<Int>) {
                val tvUnReadMsgCount = response.body()
                tvMsgCount.text = tvUnReadMsgCount.toString()
            }
        })
    }
})

现有方案如何拿到异步任务的数据,得不到就毁掉哈哈哈,就是通过回调函数来解决。
若嵌套多了,这种画风是不是有点回调地狱的感觉,俗称的「callback hell」

2.2 协程实现

mainScope.launch {
    val user = apiService.getUserInfoSuspend() //IO线程请求数据
    tvNickName.text = user?.nickName //UI线程更新界面
    val unReadMsgCount = apiService.getUnReadMsgCountSuspend(user?.token) //IO线程请求数据
    tvMsgCount.text = unReadMsgCount.toString() //UI线程更新界面
}
suspend fun getUserInfoSuspend() :User? {
    return withContext(Dispatchers.IO){
        //模拟网络请求耗时操作
        delay(10)
        User("asd123", "userName", "nickName")
    }
}

suspend fun getUnReadMsgCountSuspend(token:String?) :Int{
    return withContext(Dispatchers.IO){
        //模拟网络请求耗时操作
        delay(10)
        10
    }
}

红色框框内的就是一个协程代码块。

可以看得出在协程实现中告别了callback,所以再也不会出现回调地狱这种情况了,协程解决了回调地狱

协程可以让我们用同步的代码写出异步的效果,这也是协程最大的优势,异步代码同步去写。

小结:协程可以异步代码同步去写,解决回调地狱,让程序员更方便地处理异步业务,更方便地切线程,保证主线程安全。

它是怎么做到的?

3 它是怎么工作的(协程的原理浅析)

3.1 协程的挂起和恢复

挂起(非阻塞式挂起)

suspend 关键字,它是协程中核心的关键字,是挂起的标识。

下面看一下上述示例代码切换线程的过程:

每一次从主线程切到IO线程都是一次协程的挂起操作;

每一次从IO线程切换主线程都是一次协程的恢复操作;

挂起和恢复是suspend函数特有的能力,其他函数不具备,挂起的内容是协程,不是挂起线程,也不是挂起函数,当线程执行到suspend函数的地方,不会继续执行当前协程的代码了,所以它不会阻塞线程,是非阻塞式挂起。

有挂起必然有恢复流程, 恢复是指将已经被挂起的目标协程从挂起之处开始恢复执行。在协程中,挂起和恢复都不需要我们手动处理,这些都是kotlin协程帮我们自动完成的。

那Kotlin协程是如何帮我们自动实现挂起和恢复操作的呢?

它是通过Continuation来实现的。 [kənˌtɪnjuˈeɪʃ(ə)n] (继续;延续;连续性;后续部分)

3.2 协程的挂起和恢复的工作原理(Continuation)

CPS + 状态机

Java中没有suspend函数,suspend是Kotlin中特有的关键字,当编译时,Kotlin编译器会将含有suspend关键字的函数进行一次转换。

这种被编译器转换在kotlin中叫CPS转换(cotinuation-passing-style)。

转换流程如下所示

程序员写的挂起函数代码:

suspend fun getUserInfo() : User {
    val user = User("asd123", "userName", "nickName")
    return user
}

假想的一种中间态代码(便于理解):

fun getUserInfo(callback: Callback<User>): Any? {
    val user = User("asd123", "userName", "nickName")
    callback.onSuccess(user)
    return Unit
}

转换后的代码:

fun getUserInfo(cont: Continuation<User>): Any? {
    val user = User("asd123", "userName", "nickName")
    cont.resume(user)
    return Unit
}

我们通过Kotlin生成字节码工具查看字节码,然后将其反编译成Java代码:

@Nullable
public final Object getUserInfo(@NotNull Continuation $completion) {
   User user = new User("asd123", "userName", "nickName");
   return user;
}

这也验证了确实是会通过引入一个Continuation对象来实现恢复的流程,这里的这个Continuation对象中包含了
Callback的形态

它有两个作用:
1. 暂停并记住执行点位;2. 记住函数暂停时刻的局部变量上下文。

所以为什么我们可以用同步的方式写异步代码,是因为Continuation帮我们做了回调的流程。

下面看一下这个Continuation 的源码部分

可以看到这个Continuation中封装了一个resumeWith的方法,这个方法就是恢复用的。

internal abstract class BaseContinuationImpl() : Continuation<Any?> {


    public final override fun resumeWith(result: Result<Any?>) {
        //省略好多代码
        invokeSuspend()
        //省略好多代码
    }


    protected abstract fun invokeSuspend(result: Result<Any?>): Any?
}


internal abstract class ContinuationImpl(
    completion: Continuation<Any?>?,
    private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {

protected abstract fun invokeSuspend(result: Result<Any?>): Any?

//invokeSuspend() 这个方法是恢复的关键一步

继续看上述例子:

这是一个CPS之前的代码:

suspend fun testCoroutine() {
    val user = apiService.getUserInfoSuspend() //挂起函数  IO线程
    tvNickName.text = user?.nickName //UI线程更新界面
    val unReadMsgCount = apiService.getUnReadMsgCountSuspend(user?.token) //挂起函数  IO线程
    tvMsgCount.text = unReadMsgCount.toString() //UI线程更新界面
}

当前挂起函数里有两个挂起函数

通过kotlin编译器编译后:

fun testCoroutine(completion: Continuation<Any?>): Any? {
    // TestContinuation本质上是匿名内部类
    class TestContinuation(completion: Continuation<Any?>?) : ContinuationImpl(completion) {
        // 表示协程状态机当前的状态
        var label: Int = 0


        // 两个变量,对应原函数的2个变量
        lateinit var user: Any
        lateinit var unReadMsgCount: Int


        // result 接收协程的运行结果
        var result = continuation.result


        // suspendReturn 接收挂起函数的返回值
        var suspendReturn: Any? = null


        // CoroutineSingletons 是个枚举类
        // COROUTINE_SUSPENDED 代表当前函数被挂起了
        val sFlag = CoroutineSingletons.COROUTINE_SUSPENDED


        // invokeSuspend 是协程的关键
        // 它最终会调用 testCoroutine(this) 开启协程状态机
        // 状态机相关代码就是后面的 when 语句
        // 协程的本质,可以说就是 CPS + 状态机
        override fun invokeSuspend(_result: Result<Any?>): Any? {
            result = _result
            label = label or Int.Companion.MIN_VALUE
            return testCoroutine(this)
        }
    }


    // ...
    val continuation = if (completion is TestContinuation) {
        completion
    } else {
        //                作为参数
        //                   ↓
        TestContinuation(completion)
loop = true
while(loop) {
when (continuation.label) {
    0 -> {
        // 检测异常
        throwOnFailure(result)


        // 将 label 置为 1,准备进入下一次状态
        continuation.label = 1


        // 执行 getUserInfoSuspend(第一个挂起函数)
        suspendReturn = getUserInfoSuspend(continuation)


        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }


    1 -> {
        throwOnFailure(result)


        // 获取 user 值
        user = result as Any


        // 准备进入下一个状态
        continuation.label = 2


        // 执行 getUnReadMsgCountSuspend
        suspendReturn = getUnReadMsgCountSuspend(user.token, continuation)


        // 判断是否挂起
        if (suspendReturn == sFlag) {
            return suspendReturn
        } else {
            result = suspendReturn
            //go to next state
        }
    }


    2 -> {
        throwOnFailure(result)


        user = continuation.mUser as Any
        unReadMsgCount = continuation.unReadMsgCount as Int
        loop = false
}
}

通过一个label标签控制分支代码执行,label为0,首先会进入第一个分支,首先将label设置为下一个分支的数值,然后执行第一个suspend方法并传递当前Continuation,得到返回值,
如果是COROUTINE SUSPENDED,协程框架就直接return,协程挂起
,当第一个suspend方法执行完成,会
回调Continuation的invokeSuspend方法
,进入第二个分支执行,以此类推执行完所有suspend方法。

每一个挂起点和初始挂起点对应的 Continuation 都会转化为一种状态,协程恢复只是跳转到下一种状态中。挂起函数将执行过程分为多个 Continuation 片段,并且利用状态机的方式保证各个片段是顺序执行的。

小结:协程的挂起和恢复的本质是CPS + 状态机

4 总结

总结几个不用协程实现起来很麻烦的骚操作:

  1. 如果有一个函数,它的返回值需要等到多个耗时的异步任务都执行完毕返回之后,组合所有任务的返回值作为 最终返回值
  2. 如果有一个函数,需要顺序执行多个网络请求,并且后一个请求依赖前一个请求的执行结果
  3. 当前正在执行一项异步任务,但是你突然不想要它执行了,随时可以取消
  4. 如果你想让一个任务最多执行3秒,超过3秒则自动取消

Kotlin协程之所以被认为是假协程,是因为它并不在同一个线程运行,而是真的会创建多个线程。

Kotlin协程在Android上只是一个类似线程池的封装,真就是一个线程框架。但是它却可以让我们用同步的代码风格写出异步的效果,至于怎么做的,这个不需要我们操心,这些都是kotlin帮我们处理好了,我们需要关心的是怎么用好它

它就是一个线程框架。

作者:京东物流 王斌

来源:京东云开发者社区 自猿其说Tech 转载请注明来源

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:的卢

引入

在日常开发过程中,我们会使用很多性能优化的
API
,比如像使用
memo

useMemo
优化组件或者值,再比如使用
shouldComponentUpdate
减少组件更新频次,懒加载等等,都是一些比较好的性能优化方式,今天我将从组件设计、结构上来谈一下 React 性能优化以及数栈产品内的实践。

如何设计组件会有好的性能?

先看下面一张图:

file

这是一颗 React 组件树,
App
下面有三个子组件,分别是
Header

Content

Footer
,在
Content
组件下面又分别有
FolderTree

WorkBench

SiderBar
三个子组件,现在如果在 WorkBench 中触发一次更新,那么 React 会遍历哪些组件呢?Demo1

file

function FolderTree() {
  console.log('render FolderTree');
  return <p>folderTree</p>;
}

function SiderBar() {
  console.log('render siderBar');
  return <p>i'm SiderBar</p>;
}

export const WorkBenchGrandChild = () => {
  console.log('render WorkBenchGrandChild');
  return <p>i'm WorkBenchGrandChild</p>
};

export const WorkBenchChild = () => {
  console.log('render WorkBenchChild');
  return (
    <>
      <p>i'm WorkBenchChild</p>
      <WorkBenchGrandChild />
    </>
  );
};

function WorkBench() {
  const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
      <WorkBenchChild />
    </>
  );
}


function Content() {
  console.log('render content');
  return (
    <>
      <FolderTree />
      <WorkBench />
      <SiderBar />
    </>
  );
};

function Footer() {
  console.log('render footer');
  return <p>i'm Footer</p>
};


function Header() {
  console.log('render header');
  return <p>i'm Header</p>;
}


// Demo1
function App() {
  // const [, setStr] = useState<string>();
  return (
    <>
      <Header />
      <Content />
      <Footer />
      {/* <input onChange={(e) => { setStr(e.target.value) }} /> */}
    </>
  );
};

file

根据上面断点和日志就可以得到下面的结论:

  1. 子孙组件每触发一次更新,
    React
    都会重新遍历整颗组件树


input
输入数字,引起
updateNum
变更状态后,
react-dom

beginWork

current
由顶层组件依次遍历

  1. React
    更新时会过滤掉未变化的组件,达到减少更新的组件数的目的

在更新过程中,虽然
React
重新遍历了组件树,但 没有打印没有变化的
Header

Footer

FolderTree

SiderBar
组件内的日志

  1. 父组件状态变化,会引起子组件更新

WorkBenchChild
属于
WorkBench
的子组件,虽然
WorkBenchChild
没有变化,但仍被重新渲染,打印了输入日志,如果更近一步去断点会发现
WorkBenchChild

oldProps

newProps
是不相等的,会触发
updateFunctionComponent
更新。

综上我们可以得出一个结论,就是
React
自身会有一些性能优化的操作,会尽可能只更新变化的组件,比如 Demo1 中
WorkBench

WorkBenchChild

WorkBenchGrandChild
组件,而会绕开 不变的
Header

Footer
等组件,那么尽可能的让
React
更新的粒度就是性能优化的方向,既然尽可能只更新变化的组件,那么如何定义组件是否变化?

如何定义组件是否变化?

React
是以数据驱动视图的单向数据流,核心也就是数据,那么什么会影响数据,以及数据的承载方式,有以下几点:

  • props
  • state
  • context
  • 父组件不变!

父组件与当前组件其实没有关联性,放到这里是因为,上面的例子中
WorkBenchChild
组件中没有 state、props、context,理论上来说就不变,实际上却重新
render
了,因为 其父组件
WorkBench
有状态的变动,所以这里也提了一下,在不使用性能优化 API 的前提下,只要保证 props、state、context & 其父组件不变,那么组件就不变

还是回到刚刚的例子 Demo WorkBench

export const WorkBenchGrandChild = () => {
  console.log('render WorkBenchGrandChild');
  return <p>i'm WorkBenchGrandChild</p>
};

export const WorkBenchChild = () => {
  console.log('render WorkBenchChild');
  return (
    <>
      <p>i'm WorkBenchChild</p>
      <WorkBenchGrandChild />
    </>
  );
};

function WorkBench() {
  const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
      <WorkBenchChild />
    </>
  );
}

export default WorkBench;

看一下这个
demo

WorkBench
组件有一个
num
状态,还有一个
WorkBenchChild
的子组件,没有状态,纯渲染组件,同时
WorkBenchChild
组件也有一个 纯渲染组件
WorkBenchGrandChild
子组件,当输入
input
改变
num
的值时,
WorkBenchChild
组件 和
WorkBenchGrandChild
组件都重新渲染。我们来分析一下在
WorkBench
组件中,它的子组件
WorkBenchChild
自始至终其实都没有变化,有变化的其实是
WorkBench
中的
状态
,但是就是因为
WorkBench
中的
状态
发生了变化,导致了其子组件也一并更新,这就带来了一定的性能损耗,找到了问题,那么就需要解决问题。

如何优化?

使用性能优化 API

export const WorkBenchGrandChild = () => {
  console.log('render WorkBenchGrandChild');
  return <p>i'm WorkBenchGrandChild</p>
};

export const WorkBenchChild = React.memo(() => {
  console.log('render WorkBenchChild');
  return (
    <>
      <p>i'm WorkBenchChild</p>
      <WorkBenchGrandChild />
    </>
  );
});

// Demo WorkBench
function WorkBench() {
  const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
      <WorkBenchChild />
    </>
  );
}

export default WorkBench;

file

file

我们可以使用
React.memo()
包裹
WorkBenchChild
组件,在其
diff
的过程中
props
改为浅对比的方式达到性能优化的目的,通过断点可以知道 通过
memo
包裹的组件在
diff

oldProps

newProps
仍然不等,进入了
updateSimpleMemoComponent
中了,而
updateSimpleMemoComponent
中有个
shallowEqual
浅比较方法是结果相等的,因此没有触发更新,而是复用了组件。

状态隔离(将状态隔离到子组件中)

function ExchangeComp() {
  const [num, setNum] = useState<number>(1);
  console.log('render ExchangeComp');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
    </>
  );
};

// Demo WorkBench
function WorkBench() {
  // const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <ExchangeComp />
      <WorkBenchChild />
    </>
  );
}

export default WorkBench;

file

file

上面 Demo1 的结论,父组件更新,会触发子组件更新,就因为
WorkBench
状态改变,导致
WorkBenhChild
也更新了,这个时候可以手动创造条件,让
WorkBenchChild
的父组件也就是
WorkBench
组件剥离状态,没有状态改变,这种情况下
WorkBenchChild
满足了 父组件不变的前提,且没有
state

props

context
,那么也能够达到性能优化的结果。

对比

  1. 结果一样,都是对
    WorkBenchChild
    进行了优化,在
    WorkBench
    组件更新时,
    WorkBenchChild

    WorkBenchGrandChild
    没有重新渲染
  2. 出发点不一样,用
    memo
    性能优化 API 是直接作用到子组件上面,而状态隔离是在父组件上面操作,而受益的是其子组件

结论

  1. 只要结构写的好,性能不会太差
  2. 父组件不变,子组件可能不变

性能优化方向

  1. 找到项目中性能损耗严重的组件(节点)

在业务项目中,找到卡顿、崩溃 的组件(节点)

  1. 在根组件(节点)上使用性能优化 API

在根组件上使用的目的就是避免其祖先组件如果没有做好组件设计会给根组件带来无效的重复渲染,因为上面提到的,父组件更新,子组件也会更新

  1. 在其他节点上使用 状态隔离的方式进行优化

优化祖先组件,避免给子组件造成无效的重复渲染

总结

我们从 组件结构 和 性能优化 API 上介绍了性能优化的两种不同的优化方式,在实际项目使用上,也并非使用某一种优化方式,而是多种优化方式结合着来以达到最好的性能

产品中的部分实践

  1. 将状态隔离到子组件内部,避免引起不必要的更新

    import React, { useCallback, useEffect, useState } from 'react';
    import { connect } from 'react-redux';
    import type { SelectProps } from 'antd';
    import { Select } from 'antd';
    
    import { fetchBranchApi } from '@/api/project/optionsConfig';
    
    const BranchSelect = (props: SelectProps) => {
    	const [list, setList] = useState<string[]>([]);
    	const [loading, setLoading] = useState<boolean>(false);
    	const { projectId, project, tenantId, ...otherProps } = props;
    	const init = useCallback(async () => {
    		try {
    			setLoading(true);
    			const { code, data } = await fetchBranchApi(params);
    			if (code !== 1) return;
    			setList(data);
    		} catch (err) {
    		} finally {
    			setLoading(false);
    		}
    	}, []);
    	useEffect(() => {
    		init();
    	}, [init]);
    
    	return (
    		<Select
    			showSearch
    			optionFilterProp="children"
    			filterOption={(input, { label }) => {
    				return ((label as string) ?? '')
    					?.toLowerCase?.()
    					.includes?.(input?.toLowerCase?.());
    			}}
    			options={list?.map((value) => ({ label: value, value }))}
    			loading={loading}
    			placeholder="请选择代码分支"
    			{...otherProps}
    			/>
    	);
    };
    
    export default React.memo(BranchSelect);
    

    比如在中后台系统中很多表单型组件
    Select

    TreeSelect

    Checkbox
    ,其展示的数据需要通过接口获取,那么此时,如果将获取数据的操作放到父组件,那么每次请求数据不仅会导致需要数据的那个表单项组件更新,同时,其他的表单项也会更新,这就有一定的性能损耗,那么按照上面的例子这样将其状态封装到内部,避免请求数据影响其他组件更新,就可以达到性能优化的目的,一般建议在外层再加上
    memo
    性能优化 API,避免因为外部组件影响内部组件更新。

  2. Canvas render & Svg render

    file

    // 画一个小十字
    export function createPlus(
    		point: { x: number; y: number },
    		{ radius, lineWidth, fill }: { radius: number; lineWidth: number; fill: string }
    ) {
    		// 竖 横
    		const colWidth = point.x - (1 / 2) * lineWidth;
    		const colHeight = point.y - (1 / 2) * lineWidth - radius;
    		const colTop = 2 * radius + lineWidth;
    		const colBottom = colHeight;
    		const rowWidth = point.x - (1 / 2) * lineWidth - radius;
    		const rowHeight = point.y - (1 / 2) * lineWidth;
    		const rowRight = 2 * radius + lineWidth;
    		const rowLeft = rowWidth;
    		return `
    				<path d="M${colWidth} ${colHeight}h${lineWidth}v${colTop}h-${lineWidth}V${colBottom}z" fill="${fill}"></path>
    				<path d="M${rowWidth} ${rowHeight}h${rowRight}v${lineWidth}H${rowLeft}v-${lineWidth}z" fill="${fill}"></path>
    		`;
    }
    
    
    renderPlusSvg = throttle(() => {
    	const plusBackground = document.getElementById(`plusBackground_${this.randomKey}`);
    	const { scrollTop, scrollLeft, clientHeight, clientWidth } = this._container || {};
    	const minWidth = scrollLeft;
    	const maxWidth = minWidth + clientWidth;
    	const minHeight = scrollTop;
    	const maxHeight = minHeight + clientHeight;
    	const stepping = 30;
    	const radius = 3;
    	const fillColor = '#EBECF0';
    	const lineWidth = 1;
    	let innerHtml = '';
    	try {
    		// 根据滚动情况拿到容器的四个坐标点, 只渲染当前滚动容器内的十字,实时渲染
    		for (let x = minWidth; x < maxWidth; x += stepping) {
    			for (let y = minHeight; y < maxHeight; y += stepping) {
    				// 画十字
    				innerHtml += createPlus({ x, y }, { radius, fill: fillColor, lineWidth });
    			}
    		}
    		plusBackground.innerHTML = innerHtml;
    	} catch (e) {}
    });
    

    问题源于在大数据情况下,由 canvas 渲染的 小十字背景渲染失败,经测试,业务数据在 200条左右 canvas 画布绘制宽度就已经达到了 70000px,需要渲染的小十字 数量级在 10w 左右,canvas 不适合绘制尺寸过大的场景(超过某个阀值就会出现渲染失败,具体阀值跟浏览器有关系),而 svg 不适合绘制数量过多的场景,目前的业务场景却是 画布尺寸大,绘制元素多,后面的解决方式就是 采用 svg 渲染,将 画布渲染出来,同时监听容器的滚动事件,同时只渲染滚动容器中可视区域内的背景,实时渲染,渲染数量在 100 左右,实测就无卡顿现象,问题解决

参考:

  1. React 性能优化的一切
  2. React 源码解析之 Fiber渲染
  3. 魔术师卡颂

最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star

引言

在我们之前的讲解中,我们已经详细介绍了CPU和内存的物理结构,这是计算机系统中至关重要的组成部分。然而,除了CPU和内存之外,磁盘也扮演着非常重要的角色,它在数据存储方面起着至关重要的作用。因此,我们将继续向大家介绍磁盘的物理结构,以便更全面地了解计算机系统的工作原理。通过深入了解磁盘的物理结构,我们可以更好地理解数据的存储和访问过程,从而帮助我们更好地进行系统优化和性能提升。让我们一起深入研究磁盘的组成部分和工作原理,为进一步的学习打下坚实的基础。

磁盘

磁盘的物理结构

磁盘的物理结构指的是磁盘存储数据的特定形式。一块机械硬盘由三个关键部件组成,它们分别是盘面、磁头和悬臂。这些部件共同协作,以实现数据的读取和写入。盘面是磁盘的主要组成部分,它是一个平坦的圆盘,上面覆盖着磁性材料。磁头则是位于盘面上方和下方的装置,它们负责读取和写入数据。而悬臂则起到支撑和定位磁头的作用。这三个部件的协同工作使得磁盘能够高效地存储和访问数据。

image

image

在磁盘的物理结构中,磁盘表面被划分成了若干个同心圆的空间,这些空间被称为磁道。而磁道又按照固定大小的存储空间被划分成了扇区。

扇区是磁盘进行物理读写的最小单位。在Windows系统中,一般一个扇区的大小为512个字节。然而,在逻辑层面上,Windows对磁盘进行读写的单位是扇区整数倍的簇。根据磁盘容量和功能的不同,一个簇可以是512字节(即1个扇区)、1KB(2个扇区)、2KB、4KB、8KB、16KB、32KB(64个扇区)。因此,簇和扇区的大小是相等的。

硬盘的基本存储单位是扇区(Sector),每个扇区通常为512字节。一个硬盘通常由多个磁盘片组成,每个磁盘片上划分了若干个同心圆的磁道,每个磁道又被划分为若干个扇区。

一般情况下,一个盘面上会有两个磁头,分别位于盘面的正面和背面。盘面的正面和背面都被涂有磁性材料,用于存储数据。此外,一块硬盘通常不只有一个盘面,而是由多个盘面堆叠而成,这些盘面是平行排列的。每个盘面的正面和背面都有对应的磁头,用于读取和写入数据。

image

在数据存取的过程中,我们不能直接从磁盘盘面上将数据传输到总线上,而是需要通过磁头进行中转。首先,磁头将从盘面上读取数据,然后通过电路信号传输给控制电路和接口,最终传送到总线上。

读取数据实际上包含两个主要步骤。首先,我们需要将盘面旋转到特定位置。在这个位置上,悬臂能够准确定位到整个盘面的一个特定子区间。这个子区间的形状类似于一块披萨饼,通常被称为几何扇区(Geometrical Sector),意味着在“几何位置上”,所有这些扇区都可以被悬臂访问到。

其次,我们需要将悬臂移动到特定磁道的特定扇区,也就是在几何扇区内部定位到我们实际需要的扇区。一旦找到目标扇区,磁头会降下来,从正对着扇区的位置读取数据。

综上所述,数据的读取过程包括将盘面旋转到目标位置和将悬臂移动到目标扇区的过程。通过这两个步骤,我们可以成功读取到所需的数据。

image

磁盘缓存

正如我们之前提到的,磁盘和内存之间通常是互利共生的关系,彼此协作并保持良好的合作关系。每次内存需要读取数据时,往往会读取相同的内容,因此必然会有一个角色负责存储我们经常需要读取的内容。就像在软件开发中经常使用缓存技术一样,在硬件层面也存在磁盘缓存。

磁盘缓存指的是将从磁盘读取的数据存储到内存中的一种方式。这样,在接下来需要读取相同内容时,就不再需要通过实际的磁盘访问,而是可以直接从磁盘缓存中读取。磁盘缓存的出现大大改善了磁盘访问的速度,类似于某种技术或框架的出现,解决了特定问题。

通过磁盘缓存,我们能够提高数据的读取速度,减少对实际磁盘的频繁访问,从而提升系统的整体性能。磁盘缓存的存在使得数据的读取更加高效和快速,为我们的计算机系统带来了许多便利。

image

虚拟内存是计算机系统内存管理的一种技术,它通过将磁盘的一部分作为假想内存来使用,成为内存和磁盘交互的第二个媒介。与假想的磁盘(实际上是内存)相对的是磁盘缓存,而与假想的内存(实际上是磁盘)相对的是虚拟内存。

虚拟内存的主要作用是使应用程序认为它拥有连续可用的内存,即一个完整的地址空间。然而实际上,这个地址空间通常被分割成多个物理碎片,并且其中的一部分存储在外部磁盘管理器上,需要时进行数据交换。

虚拟内存的存在使得即使内存不足,仍然可以运行程序。例如,即使只剩下10MB的内存空间,仍然可以运行15MB的程序。然而,由于CPU只能执行加载到内存中的程序,因此虚拟内存的空间需要与内存中的空间进行置换(swap),然后才能运行程序。

虚拟内存有两种方法,分别是分页式和分段式。而Windows采用的是分页式。分页式是指在不考虑程序构造的情况下,将运行的程序按照一定大小的页进行分割,并以页为单位进行置换。在分页式中,将磁盘的内容读入内存中称为Page In,将内存的内容写入磁盘称为Page Out。Windows计算机的页大小为4KB,也就是说,需要将应用程序按照4KB的页来进行切分,并以页为单位放入磁盘中,然后进行置换。

image

为了实现虚拟内存功能,Windows在磁盘上提供了虚拟内存使用的文件,即页文件。该文件由Windows生成和管理,其大小与虚拟内存大小相同,通常是内存大小的1-2倍。

至于为什么选择分页式而不是分段式,我们在之前的文章中已经详细讨论过,所以在这里就不再赘述了。

总结

本文介绍了磁盘的物理结构、磁盘缓存和虚拟内存三个方面。磁盘的物理结构由盘面、磁头和悬臂组成,盘面上划分了多个磁道和扇区,扇区是磁盘的最小读写单位。磁盘缓存是将磁盘读取的数据存储到内存中,提高了数据读取速度和系统整体性能。虚拟内存是通过将磁盘的一部分用作假想内存的技术,使应用程序能够认为拥有连续可用的内存。虚拟内存的存在使得即使内存不足,仍然可以运行程序。通过深入了解磁盘的物理结构、磁盘缓存和虚拟内存,我们可以更好地理解计算机系统的工作原理,为系统优化和性能提升提供基础。

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。

file

一、引言

在软件开发的世界里,开发环境的选择与配置是成功项目的基础之一。特别是在Go(又名Golang)这样一个逐渐获得主流认同、在微服务和云计算领域有着广泛应用的编程语言中,选择合适的开发工具就显得尤为重要。虽然Go语言自身具有简洁、高效的特点,但好的开发工具能极大地提升开发效率,降低编程错误,并提供更深度的代码分析。

本篇文章的目标是全面但简要地介绍Golang的主流开发工具,包括但不限于各种文本编辑器和集成开发环境(IDE)。我们将会涵盖Vim, Emacs, Sublime Text, Atom, LiteIDE, Eclipse, GoLand, 和 Visual Studio Code等多种选择,并针对其中广受欢迎的GoLand和Visual Studio Code进行更为深入的安装与环境配置讲解。


二、开发环境:开发工具概览介绍

在Go语言的开发生态中,有多款编辑器和IDE(集成开发环境)可供选择。这些工具各有特点,适应不同的开发需求和使用场景。下面我们来一一了解这些工具。

Vim

  • 简要描述
    : Vim是一个高度可配置的文本编辑器,以其模式编辑功能著称。
  • 特点
    :
    • 轻量级
    • 高度可定制
    • 强大的插件生态
    • 适合远程开发

Emacs

  • 简要描述
    : Emacs是一个广泛扩展的,定制性极高的文本编辑器。
  • 特点
    :
    • 强大的代码编辑和导航功能
    • 支持多种编程语言
    • 丰富的插件库

Sublime Text

  • 简要描述
    : Sublime Text是一个跨平台的文本编辑器,拥有丰富的功能和插件。
  • 特点
    :
    • 界面美观
    • 高度可定制
    • 启动速度快

Atom

  • 简要描述
    : Atom是由GitHub推出的开源文本编辑器。
  • 特点
    :
    • 易于使用
    • 社区支持丰富
    • 高度集成Git和其他GitHub功能

LiteIDE

  • 简要描述
    : LiteIDE是一款专为Go语言开发设计的轻量级IDE。
  • 特点
    :
    • 内置Go命令支持
    • 代码编辑和调试环境一体化
    • 专为Go语言优化

Eclipse

  • 简要描述
    : Eclipse是一个著名的多语言IDE,需要安装Go插件来支持Go语言开发。
  • 特点
    :
    • 支持多种编程语言
    • 强大的社区和插件支持
    • 稳定且成熟

GoLand

  • 简要描述
    : GoLand是由JetBrains推出的一款专为Go语言开发设计的IDE。
  • 特点
    :
    • 智能代码提示
    • 内置代码分析和调试工具
    • 深度集成Go工具链

Visual Studio Code (Vscode)

  • 简要描述
    : Visual Studio Code是一款开源,轻量级但功能强大的编辑器。
  • 特点
    :
    • 高度可扩展
    • 强大的调试支持
    • 广泛的语言支持

每一款工具都有其独特的优点和不足,但它们共同的目标是提升你的开发效率和代码质量。在接下来的部分中,我们将更深入地探讨其中两个广受欢迎的工具:GoLand和Visual Studio Code,并了解如何进行环境搭建和配置。


三、GoLand工具详解

file

GoLand是由JetBrains推出的一款专为Go语言开发设计的集成开发环境(IDE)。它集成了多种高级功能,包括但不限于智能代码补全、代码分析和高级调试功能。接下来,我们将详细介绍如何下载、安装、配置Go环境以及安装Go扩展,并探索GoLand的其他高级功能。

下载与安装

获取GoLand安装包

  1. 访问GoLand的
    官方网站
  2. 根据你的操作系统选择合适的安装包。
# 示例:Linux平台下使用wget下载GoLand
wget https://download.jetbrains.com/go/goland-XXXX.X.X.tar.gz

安装GoLand

  1. 解压下载的安装包。
# 示例:Linux平台下解压安装包
tar -xzf goland-XXXX.X.X.tar.gz
  1. 执行安装程序,并按照屏幕提示进行。
# 示例:Linux平台下启动GoLand
cd goland-XXXX.X.X/bin
./goland.sh

配置Go环境

设置GOROOT和GOPATH

  1. 打开GoLand IDE。
  2. 导航至
    Settings/Preferences
    ->
    Go
    ->
    GOROOT
    ,设置Go语言的安装路径。

  3. GOPATH
    设置下,添加你的Go工作目录。
# 输出:确保GOROOT和GOPATH环境变量设置成功
echo $GOROOT  # 应输出Go的安装路径
echo $GOPATH  # 应输出Go工作目录

安装Go扩展

GoLand本身已经是一个专为Go开发设计的IDE,因此不需要安装额外的Go扩展。但是,你可以安装一些其他有用的插件来增强功能,比如数据库插件或Docker插件。

其他更多功能

智能代码补全

GoLand提供了高度智能的代码补全功能,能够根据上下文推断变量类型、函数和方法。

代码分析

GoLand具备实时代码分析功能,能够即时发现语法错误、未使用的变量或是潜在的运行时错误。

调试工具

GoLand内置了高级的调试工具,支持设置断点、单步执行、变量检查等。

版本控制

GoLand内置了Git和其他版本控制软件,可以在IDE内完成代码的拉取、提交、合并等操作。

数据库支持

GoLand提供了一个强大的数据库工具窗口,支持多种数据库,可以直接在IDE内进行数据库操作。

总结

GoLand是一款强大的Go语言IDE,拥有丰富的功能和强大的扩展性,无论你是Go语言的新手还是资深开发者,GoLand都能提供出色的开发体验。

对于更多高级功能和使用技巧,你可以访问
GoLand官方文档
进行学习。


四、VSCode工具详解

file

Visual Studio Code(简称VSCode)是一款由微软开发的开源代码编辑器。虽然它不是一个专为Go语言开发设计的IDE,但通过安装插件和配置,你可以获得非常接近于专业IDE的开发体验。本节将详细介绍如何下载、安装、配置Go环境以及安装Go扩展,并探讨VSCode的其他高级功能。

下载与安装

获取VSCode安装包

  1. 访问VSCode的
    官方网站
  2. 根据你的操作系统选择合适的安装包并下载。
# 示例:在Ubuntu下通过apt安装VSCode
sudo apt update
sudo apt install code

安装VSCode

执行下载的安装程序,并按照屏幕提示完成安装。

配置Go环境

安装Go语言支持

在VSCode中,打开扩展市场并搜索“Go”,然后安装由Go Team at Google提供的官方Go语言支持扩展。

设置GOPATH和GOROOT

  1. 打开VSCode。
  2. 打开
    Settings
    并搜索“Go Configuration”。
  3. 设置
    Go: Gopath

    Go: Goroot
    以配置Go的工作环境。
# 输出:确保GOROOT和GOPATH环境变量设置成功
echo $GOROOT  # 应输出Go的安装路径
echo $GOPATH  # 应输出Go工作目录

安装Go扩展

除了基础的Go语言支持,VSCode的扩展市场还提供了很多其他有用的Go语言扩展,例如:

  • Go Test Explorer: 用于运行和查看Go测试。
  • Go Playground: 用于快速运行和共享Go代码片段。
  • Go Lint: 用于代码质量检查。

其他更多功能

智能代码补全和代码导航

VSCode具有很好的代码补全和代码导航功能。它能够识别Go的结构体、接口、函数等,并提供快速导航。

Git集成

VSCode具有出色的Git集成功能,你可以很方便地进行代码提交、分支管理以及代码合并等操作。

调试功能

VSCode提供了一套完整的调试功能,包括设置断点、单步执行、查看变量和调用栈等。

远程开发

通过安装Remote - SSH扩展,你可以轻松地连接到远程服务器,并在服务器上进行Go语言开发。

总结

VSCode是一款非常灵活和强大的代码编辑器,通过安装和配置适当的扩展,它几乎可以与专业的Go语言IDE媲美。无论你是新手还是经验丰富的开发者,VSCode都能提供优秀的Go语言开发体验。

更多详细信息和高级功能,你可以参考
VSCode官方文档

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。

如有帮助,请多关注
TeahLead KrisChang,10+年的互联网和人工智能从业经验,10年+技术和业务团队管理经验,同济软件工程本科,复旦工程管理硕士,阿里云认证云服务资深架构师,上亿营收AI产品业务负责人。

wmproxy

wmproxy
将用
Rust
实现
http/https
代理,
socks5
代理, 反向代理, 静态文件服务器,后续将实现
websocket
代理, 内外网穿透等, 会将实现过程分享出来, 感兴趣的可以一起造个轮子法

项目地址

gite: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

日志功能

为了更容易理解程序中发生的情况,我们可能想要添加一些日志语句。通常在编写应用程序时这很容易。「在某种程度上,日志记录与使用 println! 相同,只是你可以指定消息的重要性」。
在rust中定义的日志级别有5种分别为
error

warn

info

debug

trace
定义日志的级别是表示只关系这级别的日志及更高级别的日志:

定义log,则包含所有的级别
定义warn,则只会显示
error
或者
warn
的消息

要向应用程序添加日志记录,你需要两样东西:

  1. log crate,rust官方指定的日志级别库
  2. 一个实际将日志输出写到有用位置的适配器

当下我们选用的是流行的根据环境变量指定的适配器
env_logger
,它会根据环境变量中配置的值,日志等级,或者只开启指定的库等功能,或者不同的库分配不同的等级等。


Linux
或者
MacOs
上开启功能

env RUST_LOG=debug cargo run 


Windows PowerShell
上开启功能

$env:RUST_LOG="debug"
cargo run


Windows CMD
上开启功能

set RUST_LOG="debug"
cargo run

如果我们指定库等级可以设置

RUST_LOG="info,wenmeng=warn,webparse=warn"

这样就可以减少第三方库打日志给程序带来的干扰

需要在
Cargo.toml
中引用

[dependencies]
log = "0.4.20"
env_logger = "0.10.0"

以下是示意代码

use log::{info, warn};
fn main() {
    env_logger::init();
    info!("欢迎使用软件wmproxy");
    warn!("现在已经成功启动");
}


println!
将会直接输出到
stdout
,当日志数据多的时候,无法进行关闭,做为第三方库,就不能干扰引用库的正常看日志,所以这只能调试的时候使用,或者少量的关键地方使用。

多个TcpListener的Accept

因为当前支持多个端口绑定,或者配置没有配置,存在None的情况,我们需要同时在一个线程中await所有的TcpListener。
在这里我们先用的是
tokio::select!
对多个TcpListener同时进行await。
如果此时我们没有绑定proxy的绑定地址,此时listener为None,但我们需要进行判断才知道他是否为None,如果我们用以下写法:

use tokio::net::TcpListener;
use std::io;

#[tokio::main]
async fn main() -> io::Result<()> {
    let mut listener: Option<TcpListener> = None;
    tokio::select! {
        // 加了if条件判断是否有值
        Ok((conn, addr)) = listener.as_mut().unwrap().accept(), if listener.is_some() => {
            println!("accept addr = {:?}", addr);
        }
    }
    Ok(())
}

此时我们试运行,依然报以下错误:

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', examples/udp.rs:9:46

也就是即使加了if条件我们也正确的执行我们的操作,因为tokio::select的每个分支必须返回
Fut
,此时如果为None,就不能返回
Fut
违反了该函数的定义,那么我们做以下封装:

async fn tcp_listen_work(listen: &Option<TcpListener>) -> Option<(TcpStream, SocketAddr)> {
    if listen.is_some() {
        match listen.as_ref().unwrap().accept().await {
            Ok((tcp, addr)) => Some((tcp, addr)),
            Err(_e) => None,
        }
    } else {
        // 如果为None的时候,就永远返回Poll::Pending
        let pend = std::future::pending();
        let () = pend.await;
        None
    }
}

如果为None的话,将其返回Poll::Pending,则该分支await的时候永远不会等到结果。
那么最终的的代码示意如下:

#[tokio::main]
async fn main() -> io::Result<()> {

    let listener: Option<TcpListener> = TcpListener::bind("127.0.0.1:8090").await.ok();
    tokio::select! {
        Some((conn, addr)) = tcp_listen_work(&listener) => {
            println!("accept addr = {:?}", addr);
        }
    }
    Ok(())
}

另一种在反向代理的时候因为server的数量是不定的,所以监听的TcpListener也是不定的,此时我们用
Vec<TcpListener>
来做表示,那么此时,我们如何通过
tokio::select
来一次性await所有的accept呢?
此时我们借助
futures
库中的
select_all
来监听,但是
select_all
又不允许空的Vec,因为他要返回一个Fut,空的无法返回一个Fut,所以此时我们也要对其进行封装:

async fn multi_tcp_listen_work(listens: &mut Vec<TcpListener>) -> (io::Result<(TcpStream, SocketAddr)>, usize) {
    if !listens.is_empty() {
        let (conn, index, _) = select_all(listens.iter_mut()
                .map(|listener| listener.accept().boxed())).await;
        (conn, index)
    } else {
        let pend = std::future::pending();
        let () = pend.await;
        unreachable!()
    }
}

此时监听从8091-8099,我们的最终代码:

#[tokio::main]
async fn main() -> io::Result<()> {
    let listener: Option<TcpListener> = TcpListener::bind("127.0.0.1:8090").await.ok();
    let mut listeners = vec![];
    for i in 8091..8099 {
        listeners.push(TcpListener::bind(format!("127.0.0.1:{}", i)).await?);
    }
    tokio::select! {
        Some((conn, addr)) = tcp_listen_work(&listener) => {
            println!("accept addr = {:?}", addr);
        }
        (result, index) = multi_tcp_listen_work(&mut listeners) => {
            println!("index receiver = {:?}", index)
        }
    }
    Ok(())
}

如果此时我们用

telnet 127.0.0.1 8098

那么我们就可以看到输出:

index receiver = 7

表示代码已正确的执行。

Rust中数据在多个线程中的共享

Rust中每个对象的所有权都仅只能有一个对象拥有,那么我们数据在在多个地方共享的时候可以怎么办呢?
在单线程中,我们可以用
use std::rc::Rc;

Rc的特点

  1. 单线程的引用计数
  2. 不可变引用
  3. 非线程安全,即仅能在单线程中使用
    Rc引用计数中还有一个弱引用称为
    Weak
    ,弱引用表示持有对象的一个指针,但是不添加引用计数,也不会影响数据删除,不保证一定能取得到数据。
    因为其不能修改数据,所以也常用
    RefCell
    做配合,来做引用计数的修改。
    以下是一个父类子类用弱引用计数实现的方案:
use std::rc::Rc;
use std::rc::Weak;
use std::cell::RefCell;

/// 父类拥有者
struct Owner {
    name: String,
    gadgets: RefCell<Vec<Weak<Gadget>>>,
}

/// 子类对象
struct Gadget {
    id: i32,
    owner: Rc<Owner>,
}

fn main() {
    let gadget_owner: Rc<Owner> = Rc::new(
        Owner {
            name: "wmproxy".to_string(),
            gadgets: RefCell::new(vec![]),
        }
    );
    
    // 生成两个小工具
    let gadget1 = Rc::new(
        Gadget {
            id: 1,
            owner: Rc::clone(&gadget_owner),
        }
    );
    let gadget2 = Rc::new(
        Gadget {
            id: 2,
            owner: Rc::clone(&gadget_owner),
        }
    );

    {
        let mut gadgets = gadget_owner.gadgets.borrow_mut();
        gadgets.push(Rc::downgrade(&gadget1));
        gadgets.push(Rc::downgrade(&gadget2));
    }

    for gadget_weak in gadget_owner.gadgets.borrow().iter() {
        let gadget = gadget_weak.upgrade().unwrap();
        println!("小工具 {} 的拥有者:{}", gadget.id, gadget.owner.name);
    }
}

因为其并未实现Send函数,所以无法在多线程种传递。在多线程中,我们需要用
Arc
,但是在Arc获取可变对象的时候有限制,必须他是唯一引用的时候才能修改。

use std::sync::Arc;
fn main() {
    let mut x = Arc::new(3);
    *Arc::get_mut(&mut x).unwrap() = 4;
    assert_eq!(*x, 4);
    
    let _y = Arc::clone(&x);
    assert!(Arc::get_mut(&mut x).is_none());
}

所以我们在多线程中的引用需要修改的时候,通常会用Atomic或者Mutex来做数据的写入的唯一性。

#![allow(unused)]
fn main() {
    use std::sync::{Arc, Mutex};
    use std::thread;
    use std::sync::mpsc::channel;
    
    const N: usize = 10;
    
    let data = Arc::new(Mutex::new(0));
    
    let (tx, rx) = channel();
    for _ in 0..N {
        let (data, tx) = (Arc::clone(&data), tx.clone());
        thread::spawn(move || {
            // 共享数据data,保证在线程中只会同时有一个对象拥有修改权限,也相当于拥有所有权,10个线程,每个线程+1,最终结果必须等于10
            let mut data = data.lock().unwrap();
            *data += 1;
            if *data == N {
                tx.send(()).unwrap();
            }
        });
    }
    rx.recv().unwrap();
    assert!(*data.lock().unwrap() == 10);
}

结语

以上是三种编写Rust中常碰见的情况,也是在此项目中应用解决过的方案,在了解原理的情况下,解决问题可以有不同的思路。理解了原理,你就知道他设计的初衷,更好的帮助你学习相关的Rust知识。